From aa013d4be72cb4de73e3b6be97c6d80402c5a428 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 19:07:19 -0400 Subject: [PATCH] NEO-73: wire client gather feedback on resource interact. Add skill progression client, nearest-node R picker, interaction result signal, GatherFeedbackLabel and SkillProgressionLabel HUD. --- client/scenes/main.tscn | 41 ++++- client/scripts/interaction_request_client.gd | 18 +- client/scripts/main.gd | 156 +++++++++++++++++- .../scripts/prototype_interactable_picker.gd | 68 ++++++++ .../prototype_interactable_picker.gd.uid | 1 + client/scripts/skill_progression_client.gd | 103 ++++++++++++ .../scripts/skill_progression_client.gd.uid | 1 + client/test/gather_feedback_refresh_test.gd | 86 ++++++++++ .../test/interaction_request_client_test.gd | 32 +++- .../prototype_interactable_picker_test.gd | 48 ++++++ client/test/skill_progression_client_test.gd | 97 +++++++++++ 11 files changed, 638 insertions(+), 13 deletions(-) create mode 100644 client/scripts/prototype_interactable_picker.gd create mode 100644 client/scripts/prototype_interactable_picker.gd.uid create mode 100644 client/scripts/skill_progression_client.gd create mode 100644 client/scripts/skill_progression_client.gd.uid create mode 100644 client/test/gather_feedback_refresh_test.gd create mode 100644 client/test/prototype_interactable_picker_test.gd create mode 100644 client/test/skill_progression_client_test.gd diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index c20cefb..9fc1ea0 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -14,6 +14,7 @@ [ext_resource type="Script" path="res://scripts/interactables_catalog_client.gd" id="13_ix_cat"] [ext_resource type="Script" path="res://scripts/item_definitions_client.gd" id="14_item_defs"] [ext_resource type="Script" path="res://scripts/inventory_client.gd" id="15_inventory"] +[ext_resource type="Script" path="res://scripts/skill_progression_client.gd" id="16_skill_prog"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"] geometry_collision_mask = 1 @@ -1097,6 +1098,9 @@ script = ExtResource("14_item_defs") [node name="InventoryClient" type="Node" parent="." unique_id=2500007] script = ExtResource("15_inventory") +[node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008] +script = ExtResource("16_skill_prog") + [node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001] [node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002] @@ -1156,11 +1160,25 @@ theme_override_font_sizes/font_size = 15 autowrap_mode = 3 text = "Cast: —" +[node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008] +offset_left = 8.0 +offset_top = 376.0 +offset_right = 520.0 +offset_bottom = 402.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_override_colors/font_color = Color(0.88, 0.95, 0.72, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 6 +theme_override_font_sizes/font_size = 14 +autowrap_mode = 3 +text = "Gather: —" + [node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006] offset_left = 8.0 -offset_top = 384.0 +offset_top = 408.0 offset_right = 520.0 -offset_bottom = 460.0 +offset_bottom = 484.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1) @@ -1172,9 +1190,9 @@ text = "Cooldowns: —" [node name="InventoryLabel" type="Label" parent="UICanvas" unique_id=9000007] offset_left = 8.0 -offset_top = 472.0 +offset_top = 496.0 offset_right = 520.0 -offset_bottom = 620.0 +offset_bottom = 644.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 1) @@ -1184,3 +1202,18 @@ theme_override_font_sizes/font_size = 14 autowrap_mode = 3 text = "Inventory: (refresh: I) Loading…" + +[node name="SkillProgressionLabel" type="Label" parent="UICanvas" unique_id=9000009] +offset_left = 8.0 +offset_top = 656.0 +offset_right = 520.0 +offset_bottom = 720.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_override_colors/font_color = Color(0.82, 0.9, 1, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 6 +theme_override_font_sizes/font_size = 14 +autowrap_mode = 3 +text = "Skills: +Loading…" diff --git a/client/scripts/interaction_request_client.gd b/client/scripts/interaction_request_client.gd index a815bd9..e76ff5d 100644 --- a/client/scripts/interaction_request_client.gd +++ b/client/scripts/interaction_request_client.gd @@ -1,11 +1,13 @@ extends Node ## NS-18 / NEO-25: POST InteractionRequest; prints server allow/deny to Output (prototype). -## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → -## [code]prototype_resource_node_alpha[/code] (stable contract ids — must match server registry). +## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → nearest in-range +## [code]resource_node[/code] ([code]main.gd[/code] + [code]prototype_interactable_picker.gd[/code]). ## Key handling is delegated from ## [code]main.gd[/code] [method Node._unhandled_key_input] (see [method post_interact_terminal] / -## [method post_interact_resource]) so the embedded Game dock still receives E/R reliably. +## [method post_interact_id]) so the embedded Game dock still receives E/R reliably. + +signal interaction_result_received(interactable_id: String, allowed: bool, reason_code: String) const ID_TERMINAL := "prototype_terminal" const ID_RESOURCE_NODE := "prototype_resource_node_alpha" @@ -44,6 +46,10 @@ func post_interact_resource() -> void: _post_interact(ID_RESOURCE_NODE) +func post_interact_id(interactable_id: String) -> void: + _post_interact(interactable_id) + + func _base_root() -> String: return base_url.strip_edges().rstrip("/") @@ -88,10 +94,14 @@ func _on_request_completed( var d: Dictionary = parsed var allowed: bool = bool(d.get("allowed", false)) var reason: Variant = d.get("reasonCode", null) + var reason_str := "" + if not allowed and reason != null: + reason_str = str(reason) if allowed: print("Interaction [%s]: allowed=true (reasonCode omitted on success)" % id_echo) else: - print("Interaction [%s]: allowed=false reasonCode=%s" % [id_echo, str(reason)]) + print("Interaction [%s]: allowed=false reasonCode=%s" % [id_echo, reason_str]) + interaction_result_received.emit(id_echo, allowed, reason_str) _try_flush_pending() return print("Interaction [%s]: HTTP %s body=%s" % [id_echo, response_code, text]) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index fd986d6..f083893 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -39,9 +39,12 @@ const AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: float = 0.55 const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12 const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd") +const PrototypeInteractablePicker := preload("res://scripts/prototype_interactable_picker.gd") const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd") const CooldownStateScript := preload("res://scripts/cooldown_state.gd") const COOLDOWN_REASON_ON_CD := "on_cooldown" +const SCRAP_METAL_BULK_ID := "scrap_metal_bulk" +const SALVAGE_SKILL_ID := "salvage" ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 @@ -76,6 +79,11 @@ var _cooldown_client: Node = null var _cooldown_poll_timer: Timer = null var _last_inventory_snapshot: Dictionary = {} var _inventory_error: String = "" +var _interactables_catalog: Array = [] +var _last_progression_snapshot: Dictionary = {} +var _progression_error: String = "" +var _pending_gather_feedback: bool = false +var _gather_pre_scrap_qty: int = 0 @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D @onready var _world: Node3D = $World @@ -91,8 +99,11 @@ var _inventory_error: String = "" @onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel @onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel @onready var _inventory_label: Label = $UICanvas/InventoryLabel +@onready var _gather_feedback_label: Label = $UICanvas/GatherFeedbackLabel +@onready var _skill_progression_label: Label = $UICanvas/SkillProgressionLabel @onready var _inventory_client: Node = $InventoryClient @onready var _item_defs_client: Node = $ItemDefinitionsClient +@onready var _skill_progression_client: Node = $SkillProgressionClient @onready var _target_client: Node = $TargetSelectionClient @onready var _interaction_client: Node = $InteractionRequestClient @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @@ -135,6 +146,8 @@ func _ready() -> void: _target_client.call("request_sync_from_server") _setup_hotbar_loadout_sync() _setup_inventory_sync() + _setup_skill_progression_sync() + _setup_gather_interact_feedback() func _physics_process(_delta: float) -> void: @@ -354,12 +367,16 @@ func _on_inventory_received(snapshot: Dictionary) -> void: _inventory_error = "" _last_inventory_snapshot = snapshot.duplicate(true) _render_inventory_label() + _finalize_pending_gather_feedback() func _on_inventory_sync_failed(reason: String) -> void: _inventory_error = reason _last_inventory_snapshot = {} _render_inventory_label() + if _pending_gather_feedback: + _pending_gather_feedback = false + _render_gather_feedback_label("Gather: ok — inventory refresh failed") func _render_inventory_label() -> void: @@ -432,6 +449,128 @@ func _request_inventory_refresh() -> void: _inventory_client.call("request_sync_from_server") +func _setup_skill_progression_sync() -> void: + # NEO-73: salvage XP/level HUD row; boot hydrate + refresh after gather. + _apply_authority_http_config_to_client(_skill_progression_client) + if _skill_progression_client.has_signal("progression_received"): + _skill_progression_client.connect( + "progression_received", Callable(self, "_on_progression_received") + ) + if _skill_progression_client.has_signal("progression_sync_failed"): + _skill_progression_client.connect( + "progression_sync_failed", Callable(self, "_on_progression_sync_failed") + ) + _render_skill_progression_label() + if _skill_progression_client.has_method("request_sync_from_server"): + _skill_progression_client.call("request_sync_from_server") + + +func _setup_gather_interact_feedback() -> void: + if not is_instance_valid(_interaction_client): + return + if _interaction_client.has_signal("interaction_result_received"): + _interaction_client.connect( + "interaction_result_received", Callable(self, "_on_interaction_result_for_gather") + ) + _render_gather_feedback_label() + + +func _on_progression_received(snapshot: Dictionary) -> void: + _progression_error = "" + _last_progression_snapshot = snapshot.duplicate(true) + _render_skill_progression_label() + + +func _on_progression_sync_failed(reason: String) -> void: + _progression_error = reason + _last_progression_snapshot = {} + _render_skill_progression_label() + + +func _render_skill_progression_label() -> void: + if not is_instance_valid(_skill_progression_label): + return + var header := "Skills:" + if not _progression_error.is_empty(): + _skill_progression_label.text = "%s\nerror — %s" % [header, _progression_error] + return + if _last_progression_snapshot.is_empty(): + _skill_progression_label.text = "%s\nLoading…" % header + return + var salvage_row: Dictionary = _skill_row(SALVAGE_SKILL_ID) + if salvage_row.is_empty(): + _skill_progression_label.text = "%s\nsalvage: — (missing row)" % header + return + var level: int = int(salvage_row.get("level", 0)) + var xp: int = int(salvage_row.get("xp", 0)) + _skill_progression_label.text = "%s\nsalvage: level %d, xp %d" % [header, level, xp] + + +func _skill_row(skill_id: String) -> Dictionary: + if not is_instance_valid(_skill_progression_client): + return {} + if not _skill_progression_client.has_method("skill_row"): + return {} + return _skill_progression_client.call("skill_row", skill_id, _last_progression_snapshot) as Dictionary + + +func _render_gather_feedback_label(text: String = "Gather: —") -> void: + if is_instance_valid(_gather_feedback_label): + _gather_feedback_label.text = text + + +func _on_interaction_result_for_gather( + interactable_id: String, allowed: bool, reason_code: String +) -> void: + if not PrototypeInteractablePicker.is_resource_node_id(_interactables_catalog, interactable_id): + return + if not allowed: + _pending_gather_feedback = false + var rc := reason_code.strip_edges() + if rc.is_empty(): + _render_gather_feedback_label("Gather: denied (no reasonCode)") + else: + _render_gather_feedback_label("Gather: denied — %s" % rc) + return + _request_inventory_refresh() + if ( + is_instance_valid(_skill_progression_client) + and _skill_progression_client.has_method("request_sync_from_server") + ): + _skill_progression_client.call("request_sync_from_server") + + +func _total_bag_qty(item_id: String, snapshot: Dictionary = {}) -> int: + var source: Dictionary = snapshot + if source.is_empty(): + source = _last_inventory_snapshot + var bag: Variant = source.get("bagSlots", null) + if bag == null or not bag is Array: + return 0 + var total: int = 0 + for slot_variant in bag as Array: + if not slot_variant is Dictionary: + continue + var slot: Dictionary = slot_variant + if str(slot.get("itemId", "")) != item_id: + continue + total += int(slot.get("quantity", 0)) + return total + + +func _finalize_pending_gather_feedback() -> void: + if not _pending_gather_feedback: + return + _pending_gather_feedback = false + var new_qty: int = _total_bag_qty(SCRAP_METAL_BULK_ID) + var delta: int = new_qty - _gather_pre_scrap_qty + if delta <= 0: + _render_gather_feedback_label("Gather: ok (no scrap delta)") + return + var label: String = _inventory_item_label(SCRAP_METAL_BULK_ID) + _render_gather_feedback_label("Gather: +%d %s" % [delta, label]) + + func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void: if ( is_instance_valid(_cooldown_client) @@ -579,7 +718,7 @@ func _try_interact_key_input(event: InputEvent) -> bool: _forward_interact_post("post_interact_terminal") return true if event.is_action_pressed("interact_secondary"): - _forward_interact_post("post_interact_resource") + _forward_interact_resource_nearest() return true if event is InputEventKey: var k := event as InputEventKey @@ -588,7 +727,7 @@ func _try_interact_key_input(event: InputEvent) -> bool: _forward_interact_post("post_interact_terminal") return true if k.physical_keycode == KEY_R or k.keycode == KEY_R: - _forward_interact_post("post_interact_resource") + _forward_interact_resource_nearest() return true return false @@ -664,6 +803,18 @@ func _forward_interact_post(method: String) -> void: _interaction_client.call(method) +func _forward_interact_resource_nearest() -> void: + if not is_instance_valid(_interaction_client) or not is_instance_valid(_player): + return + var node_id: String = PrototypeInteractablePicker.nearest_resource_node_id( + _interactables_catalog, _player.global_position + ) + _gather_pre_scrap_qty = _total_bag_qty(SCRAP_METAL_BULK_ID) + _pending_gather_feedback = true + if _interaction_client.has_method("post_interact_id"): + _interaction_client.call("post_interact_id", node_id) + + func _dev_toggle_obstacle_smoke_deferred() -> void: var obstacle := _dev_obstacle_smoke if obstacle == null or not is_instance_valid(obstacle): @@ -717,6 +868,7 @@ func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void: func _on_interactables_catalog_ready(descriptors: Array) -> void: + _interactables_catalog = descriptors.duplicate(true) var groups: Variant = load("res://scripts/interactable_world_builder.gd").call( "build_from_catalog", descriptors, _interactables_root, _radius_preview ) diff --git a/client/scripts/prototype_interactable_picker.gd b/client/scripts/prototype_interactable_picker.gd new file mode 100644 index 0000000..227f6b3 --- /dev/null +++ b/client/scripts/prototype_interactable_picker.gd @@ -0,0 +1,68 @@ +extends Object + +## NEO-73: client-side nearest [code]resource_node[/code] picker for gather interact ([kbd]R[/kbd]). +## Horizontal X/Z distance + inclusive radius — same rule as [code]interaction_radius_indicators.gd[/code]. + +const KIND_RESOURCE_NODE := "resource_node" +const FALLBACK_RESOURCE_NODE_ID := "prototype_resource_node_alpha" + +const KNOWN_RESOURCE_NODE_IDS: Array[String] = [ + "prototype_resource_node_alpha", + "prototype_subsurface_vein_beta", + "prototype_bio_mat_gamma", + "prototype_urban_bulk_delta", +] + + +static func nearest_resource_node_id(descriptors: Array, world: Vector3) -> String: + var best_id := "" + var best_dist_sq: float = INF + for descriptor_variant in descriptors: + if not descriptor_variant is Dictionary: + continue + var descriptor: Dictionary = descriptor_variant + if str(descriptor.get("kind", "")) != KIND_RESOURCE_NODE: + continue + var interactable_id: String = str(descriptor.get("interactableId", "")) + if interactable_id.is_empty(): + continue + var dist_sq: float = _horizontal_distance_sq_to_descriptor(descriptor, world) + var radius: float = float(descriptor.get("interactionRadius", 0.0)) + if dist_sq > radius * radius: + continue + if ( + best_id.is_empty() + or dist_sq < best_dist_sq - 1e-9 + or (absf(dist_sq - best_dist_sq) <= 1e-9 and interactable_id < best_id) + ): + best_dist_sq = dist_sq + best_id = interactable_id + if best_id.is_empty(): + return FALLBACK_RESOURCE_NODE_ID + return best_id + + +static func descriptor_kind(descriptors: Array, interactable_id: String) -> String: + for descriptor_variant in descriptors: + if not descriptor_variant is Dictionary: + continue + var descriptor: Dictionary = descriptor_variant + if str(descriptor.get("interactableId", "")) == interactable_id: + return str(descriptor.get("kind", "")) + return "" + + +static func is_resource_node_id(descriptors: Array, interactable_id: String) -> bool: + if descriptor_kind(descriptors, interactable_id) == KIND_RESOURCE_NODE: + return true + return KNOWN_RESOURCE_NODE_IDS.has(interactable_id) + + +static func _horizontal_distance_sq_to_descriptor(descriptor: Dictionary, world: Vector3) -> float: + var anchor_variant: Variant = descriptor.get("anchor", null) + if not anchor_variant is Dictionary: + return INF + var anchor: Dictionary = anchor_variant + var dx: float = world.x - float(anchor.get("x", 0.0)) + var dz: float = world.z - float(anchor.get("z", 0.0)) + return dx * dx + dz * dz diff --git a/client/scripts/prototype_interactable_picker.gd.uid b/client/scripts/prototype_interactable_picker.gd.uid new file mode 100644 index 0000000..c0c0557 --- /dev/null +++ b/client/scripts/prototype_interactable_picker.gd.uid @@ -0,0 +1 @@ +uid://bneo73ixpicker01 diff --git a/client/scripts/skill_progression_client.gd b/client/scripts/skill_progression_client.gd new file mode 100644 index 0000000..70a9a5b --- /dev/null +++ b/client/scripts/skill_progression_client.gd @@ -0,0 +1,103 @@ +extends Node + +## NEO-73: prototype HTTP client for [code]GET /game/players/{id}/skill-progression[/code] (NEO-37). + +signal progression_received(snapshot: Dictionary) +signal progression_sync_failed(reason: String) + +const SCHEMA_VERSION := 1 + +@export var base_url: String = "http://127.0.0.1:5253" +@export var dev_player_id: String = "dev-local-1" +@export var injected_http: Node = null + +var _http: Node +var _busy: bool = false + + +func _ready() -> void: + if injected_http != null: + _http = injected_http + else: + _http = HTTPRequest.new() + add_child(_http) + if _http is HTTPRequest: + (_http as HTTPRequest).timeout = 30.0 + @warning_ignore("unsafe_method_access") + _http.request_completed.connect(_on_request_completed) + + +func request_sync_from_server() -> void: + if _busy: + return + _busy = true + var url := "%s/game/players/%s/skill-progression" % [_base_root(), _player_path_segment()] + var err: Error = _http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("SkillProgressionClient: %s" % reason) + _busy = false + progression_sync_failed.emit(reason) + + +func skill_row(skill_id: String, snapshot: Dictionary = {}) -> Dictionary: + var skills: Variant = snapshot.get("skills", null) + if skills == null or not skills is Array: + return {} + for row_variant in skills as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("id", "")) == skill_id: + return row + return {} + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _player_path_segment() -> String: + return dev_player_id.strip_edges() + + +static func parse_progression_json(text: String) -> Variant: + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + return null + var root: Dictionary = parsed + if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION: + return null + var skills: Variant = root.get("skills", null) + if skills == null or not skills is Array: + return null + return root + + +func _on_request_completed( + result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + _busy = false + if result != HTTPRequest.RESULT_SUCCESS: + var reason := "HTTP failed (result=%s)" % result + push_warning("SkillProgressionClient: %s" % reason) + progression_sync_failed.emit(reason) + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("SkillProgressionClient: %s" % reason404) + progression_sync_failed.emit(reason404) + 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) + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_progression_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("SkillProgressionClient: %s" % reason_json) + progression_sync_failed.emit(reason_json) + return + progression_received.emit(snapshot as Dictionary) diff --git a/client/scripts/skill_progression_client.gd.uid b/client/scripts/skill_progression_client.gd.uid new file mode 100644 index 0000000..4ec415c --- /dev/null +++ b/client/scripts/skill_progression_client.gd.uid @@ -0,0 +1 @@ +uid://bneo73skillprog01 diff --git a/client/test/gather_feedback_refresh_test.gd b/client/test/gather_feedback_refresh_test.gd new file mode 100644 index 0000000..e5c6fc9 --- /dev/null +++ b/client/test/gather_feedback_refresh_test.gd @@ -0,0 +1,86 @@ +extends GdUnitTestSuite + +## NEO-73: allowed resource-node interact triggers inventory + skill refresh. + +const IxClient := preload("res://scripts/interaction_request_client.gd") +const InventoryClient := preload("res://scripts/inventory_client.gd") +const SkillProgressionClient := preload("res://scripts/skill_progression_client.gd") +const Picker := preload("res://scripts/prototype_interactable_picker.gd") + + +class MockInteractTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var last_body: String = "" + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_POST, + request_data: String = "" + ) -> Error: + last_body = request_data + var body_bytes: PackedByteArray = ( + '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer() + ) + request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes) + return OK + + +class SpySyncClient: + extends Node + var sync_calls: int = 0 + + func request_sync_from_server() -> void: + sync_calls += 1 + + +class GatherRefreshHarness: + extends Node + var inventory_sync_calls: int = 0 + var skill_sync_calls: int = 0 + var _inventory: SpySyncClient + var _skill: SpySyncClient + var _ix: Node + + func setup(parent: Node, ix_transport: Node) -> void: + _inventory = SpySyncClient.new() + _skill = SpySyncClient.new() + _ix = IxClient.new() + _ix.set("injected_http", ix_transport) + parent.add_child(_ix) + parent.add_child(_inventory) + parent.add_child(_skill) + _ix.connect("interaction_result_received", Callable(self, "_on_interaction_result")) + + func _on_interaction_result( + interactable_id: String, allowed: bool, _reason_code: String + ) -> void: + if not Picker.is_resource_node_id([], interactable_id): + return + if not allowed: + return + _inventory.request_sync_from_server() + _skill.request_sync_from_server() + + func post_resource_alpha() -> void: + _ix.call("post_interact_id", "prototype_resource_node_alpha") + + +func test_allowed_resource_interact_triggers_both_refreshes() -> void: + # Arrange + var transport := MockInteractTransport.new() + var harness := GatherRefreshHarness.new() + auto_free(transport) + auto_free(harness) + add_child(harness) + harness.setup(self, transport) + monitor_signals(harness._ix) + # Act + harness.post_resource_alpha() + # Assert + assert_signal(harness._ix).is_emitted("interaction_result_received") + assert_that(harness._inventory.sync_calls).is_equal(1) + assert_that(harness._skill.sync_calls).is_equal(1) diff --git a/client/test/interaction_request_client_test.gd b/client/test/interaction_request_client_test.gd index 86980bc..25ab535 100644 --- a/client/test/interaction_request_client_test.gd +++ b/client/test/interaction_request_client_test.gd @@ -12,18 +12,19 @@ class MockHttpTransport: ) var last_url: String = "" var last_body: String = "" - var last_method: HTTPClient.Method = HTTPClient.METHOD_GET + var last_method: HTTPClient.Method = HTTPClient.Method.METHOD_GET + var body_json: String = '{"schemaVersion":1,"allowed":true}' func request( url: String, _custom_headers: PackedStringArray = PackedStringArray(), - method: HTTPClient.Method = HTTPClient.METHOD_GET, + method: HTTPClient.Method = HTTPClient.Method.METHOD_GET, request_data: String = "" ) -> Error: last_url = url last_body = request_data last_method = method - var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer() + var body_bytes: PackedByteArray = body_json.to_utf8_buffer() request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes) return OK @@ -106,6 +107,31 @@ func test_post_interact_resource_public_entrypoint() -> void: assert_that(transport.last_body).contains("prototype_resource_node_alpha") +func test_interaction_result_emits_allow_signal() -> void: + # Arrange + var transport := MockHttpTransport.new() + var ix := _make_ix(transport) + monitor_signals(ix) + # Act + ix.call("post_interact_id", "prototype_resource_node_alpha") + # Assert + assert_signal(ix).is_emitted("interaction_result_received", "prototype_resource_node_alpha", true, "") + + +func test_interaction_result_emits_deny_signal() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = '{"schemaVersion":1,"allowed":false,"reasonCode":"node_depleted"}' + var ix := _make_ix(transport) + monitor_signals(ix) + # Act + ix.call("post_interact_id", "prototype_urban_bulk_delta") + # Assert + assert_signal(ix).is_emitted( + "interaction_result_received", "prototype_urban_bulk_delta", false, "node_depleted" + ) + + func test_last_wins_second_press_before_first_completes() -> void: # Arrange var transport := HoldFirstThenAutoTransport.new() diff --git a/client/test/prototype_interactable_picker_test.gd b/client/test/prototype_interactable_picker_test.gd new file mode 100644 index 0000000..622c71d --- /dev/null +++ b/client/test/prototype_interactable_picker_test.gd @@ -0,0 +1,48 @@ +extends GdUnitTestSuite + +## NEO-73: nearest in-range resource node picker for gather interact. + +const Picker := preload("res://scripts/prototype_interactable_picker.gd") + + +static func _descriptor(id: String, kind: String, x: float, z: float, radius: float) -> Dictionary: + return { + "interactableId": id, + "kind": kind, + "anchor": {"x": x, "y": 0.5, "z": z}, + "interactionRadius": radius, + } + + +func test_nearest_resource_node_picks_closest_in_range() -> void: + # Arrange + var descriptors: Array = [ + _descriptor("prototype_terminal", "terminal", 0.0, 0.0, 3.0), + _descriptor("prototype_resource_node_alpha", "resource_node", 12.0, -6.0, 3.0), + _descriptor("prototype_subsurface_vein_beta", "resource_node", 18.0, -6.0, 3.0), + ] + var world := Vector3(17.0, 0.9, -6.0) + # Act + var picked: String = Picker.nearest_resource_node_id(descriptors, world) + # Assert + assert_that(picked).is_equal("prototype_subsurface_vein_beta") + + +func test_nearest_resource_node_falls_back_to_alpha_when_out_of_range() -> void: + # Arrange + var descriptors: Array = [ + _descriptor("prototype_resource_node_alpha", "resource_node", 12.0, -6.0, 3.0), + ] + var world := Vector3(-5.0, 0.9, -5.0) + # Act + var picked: String = Picker.nearest_resource_node_id(descriptors, world) + # Assert + assert_that(picked).is_equal("prototype_resource_node_alpha") + + +func test_is_resource_node_id_uses_known_ids_when_catalog_empty() -> void: + # Arrange + # Act + var is_node: bool = Picker.is_resource_node_id([], "prototype_bio_mat_gamma") + # Assert + assert_that(is_node).is_true() diff --git a/client/test/skill_progression_client_test.gd b/client/test/skill_progression_client_test.gd new file mode 100644 index 0000000..a5c3d0d --- /dev/null +++ b/client/test/skill_progression_client_test.gd @@ -0,0 +1,97 @@ +extends GdUnitTestSuite + +## NEO-73: `SkillProgressionClient` GET parse + failure signals. + +const SkillProgressionClient := preload("res://scripts/skill_progression_client.gd") + +var _progression_capture: Dictionary = {} + + +func _capture_progression(snapshot: Dictionary) -> void: + _progression_capture = snapshot + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var last_url: String = "" + 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: + last_url = url + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + return OK + + +static func _trio_progression_json() -> String: + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","skills":[' + + '{"id":"salvage","xp":10,"level":1},' + + '{"id":"refine","xp":0,"level":1},' + + '{"id":"intrusion","xp":0,"level":1}' + + "]}" + ) + + +func _make_client(transport: Node) -> Node: + var c: Node = SkillProgressionClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_progression_json_reads_salvage_row() -> void: + # Arrange + var json := _trio_progression_json() + # Act + var snapshot: Variant = SkillProgressionClient.parse_progression_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var c: Node = SkillProgressionClient.new() + auto_free(c) + var row: Dictionary = c.call("skill_row", "salvage", snapshot as Dictionary) + assert_that(int(row.get("xp", -1))).is_equal(10) + assert_that(int(row.get("level", -1))).is_equal(1) + + +func test_request_sync_emits_progression_received() -> void: + # Arrange + _progression_capture = {} + var transport := MockHttpTransport.new() + transport.body_json = _trio_progression_json() + var c := _make_client(transport) + c.connect("progression_received", Callable(self, "_capture_progression")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + assert_signal(c).is_emitted("progression_received") + assert_that(_progression_capture.has("skills")).is_true() + assert_that(transport.last_url).contains("/skill-progression") + + +func test_http_404_emits_progression_sync_failed() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.response_code = 404 + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")