From 751fea3db42fb470500b62863048975e32bdeee6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 18:41:24 -0400 Subject: [PATCH] NEO-72: add client inventory snapshot HUD and HTTP clients. Wire GET inventory + item-definitions into InventoryLabel with I-key refresh, displayName labels, and GdUnit coverage for happy/error paths. --- client/README.md | 10 ++ client/project.godot | 5 + client/scenes/main.tscn | 23 ++++ client/scripts/inventory_client.gd | 87 ++++++++++++ client/scripts/inventory_client.gd.uid | 1 + client/scripts/item_definitions_client.gd | 97 +++++++++++++ client/scripts/item_definitions_client.gd.uid | 1 + client/scripts/main.gd | 120 +++++++++++++++++ client/test/inventory_client_test.gd | 99 ++++++++++++++ client/test/item_definitions_client_test.gd | 127 ++++++++++++++++++ docs/manual-qa/NEO-72.md | 34 +++++ 11 files changed, 604 insertions(+) create mode 100644 client/scripts/inventory_client.gd create mode 100644 client/scripts/inventory_client.gd.uid create mode 100644 client/scripts/item_definitions_client.gd create mode 100644 client/scripts/item_definitions_client.gd.uid create mode 100644 client/test/inventory_client_test.gd create mode 100644 client/test/item_definitions_client_test.gd create mode 100644 docs/manual-qa/NEO-72.md diff --git a/client/README.md b/client/README.md index ab3af26..68106c9 100644 --- a/client/README.md +++ b/client/README.md @@ -134,6 +134,16 @@ This story hydrates bindings only; cast execution and cooldown behavior stay in Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cast deny/accept HUD: [`docs/manual-qa/NEO-28.md`](../docs/manual-qa/NEO-28.md). +## Inventory snapshot HUD (NEO-72) + +- **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http). +- **`GET /game/world/item-definitions`** — cached on boot for **`displayName`** labels (NEO-53). +- **Scripts:** `scripts/inventory_client.gd`, `scripts/item_definitions_client.gd`; wired from `main.gd` on boot and on **`inventory_refresh`** (**I**). +- **HUD:** `UICanvas/InventoryLabel` lists **non-empty bag** rows and **equipment slot 0** (shows **`— empty`** when unoccupied). Failed GET paints **`error — …`** on the label and **`push_warning`** in Output. +- **Refresh:** press **I** (or call **`InventoryClient.request_sync_from_server()`** from `main.gd` helpers — entrypoint for NEO-73 gather/craft refresh). + +Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md). + ### Manual check (NEO-24) 1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`. diff --git a/client/project.godot b/client/project.godot index ce46c18..e36fe4e 100644 --- a/client/project.godot +++ b/client/project.godot @@ -138,6 +138,11 @@ hotbar_slot_8={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":56,"physical_keycode":56,"key_label":0,"unicode":56,"location":0,"echo":false,"script":null) ] } +inventory_refresh={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":73,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null) +] +} [physics] diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index f260701..c20cefb 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -12,6 +12,8 @@ [ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"] [ext_resource type="Script" uid="uid://dqm8s4k2h1xwy" path="res://scripts/prototype_smooth_hill_piece.gd" id="10_smooth_hill"] [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"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"] geometry_collision_mask = 1 @@ -1089,6 +1091,12 @@ script = ExtResource("5_ix") [node name="TargetSelectionClient" type="Node" parent="." unique_id=2500004] script = ExtResource("11_tgt_sel") +[node name="ItemDefinitionsClient" type="Node" parent="." unique_id=2500006] +script = ExtResource("14_item_defs") + +[node name="InventoryClient" type="Node" parent="." unique_id=2500007] +script = ExtResource("15_inventory") + [node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001] [node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002] @@ -1161,3 +1169,18 @@ theme_override_constants/outline_size = 6 theme_override_font_sizes/font_size = 14 autowrap_mode = 3 text = "Cooldowns: —" + +[node name="InventoryLabel" type="Label" parent="UICanvas" unique_id=9000007] +offset_left = 8.0 +offset_top = 472.0 +offset_right = 520.0 +offset_bottom = 620.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 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 = "Inventory: (refresh: I) +Loading…" diff --git a/client/scripts/inventory_client.gd b/client/scripts/inventory_client.gd new file mode 100644 index 0000000..3ba2f2d --- /dev/null +++ b/client/scripts/inventory_client.gd @@ -0,0 +1,87 @@ +extends Node + +## NEO-72: prototype HTTP client for [code]GET /game/players/{id}/inventory[/code] (NEO-55). + +signal inventory_received(snapshot: Dictionary) +signal inventory_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/inventory" % [_base_root(), _player_path_segment()] + var err: Error = _http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("InventoryClient: %s" % reason) + _busy = false + inventory_sync_failed.emit(reason) + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _player_path_segment() -> String: + return dev_player_id.strip_edges() + + +static func parse_inventory_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 + return root + + +func _on_request_completed( + result: int, response_code: int, _headers: PackedByteArray, body: PackedByteArray +) -> void: + _busy = false + if result != HTTPRequest.RESULT_SUCCESS: + var reason := "HTTP failed (result=%s)" % result + push_warning("InventoryClient: %s" % reason) + inventory_sync_failed.emit(reason) + return + if response_code == 404: + var reason404 := "HTTP 404 (player unknown)" + push_warning("InventoryClient: %s" % reason404) + inventory_sync_failed.emit(reason404) + 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) + return + var text := body.get_string_from_utf8() + var snapshot: Variant = parse_inventory_json(text) + if snapshot == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("InventoryClient: %s" % reason_json) + inventory_sync_failed.emit(reason_json) + return + inventory_received.emit(snapshot as Dictionary) diff --git a/client/scripts/inventory_client.gd.uid b/client/scripts/inventory_client.gd.uid new file mode 100644 index 0000000..26c4b8c --- /dev/null +++ b/client/scripts/inventory_client.gd.uid @@ -0,0 +1 @@ +uid://bneo72inventory01 diff --git a/client/scripts/item_definitions_client.gd b/client/scripts/item_definitions_client.gd new file mode 100644 index 0000000..1dda1ab --- /dev/null +++ b/client/scripts/item_definitions_client.gd @@ -0,0 +1,97 @@ +extends Node + +## NEO-72: fetches [code]GET /game/world/item-definitions[/code] (NEO-53) and caches id → display metadata +## for inventory HUD labels. + +signal definitions_ready(definitions_by_id: Dictionary) + +const SCHEMA_VERSION := 1 + +@export var base_url: String = "http://127.0.0.1:5253" +@export var injected_http: Node = null + +var _http: Node +var _busy: bool = false +var _definitions_by_id: Dictionary = {} + + +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/world/item-definitions" % _base_root() + var err: Error = _http.request(url) + if err != OK: + push_warning("ItemDefinitionsClient: GET failed to start (%s)" % err) + _busy = false + + +func display_name_for(item_id: String) -> String: + var row: Variant = _definitions_by_id.get(item_id, null) + if row is Dictionary: + var dn: Variant = (row as Dictionary).get("displayName", "") + if dn is String and not (dn as String).is_empty(): + return dn as String + return item_id + + +func definitions_map() -> Dictionary: + return _definitions_by_id.duplicate(true) + + +static func build_definitions_map(root: Dictionary) -> Dictionary: + var out: Dictionary = {} + var raw_items: Variant = root.get("items", null) + if raw_items == null or not raw_items is Array: + return out + for item in raw_items as Array: + if not item is Dictionary: + continue + var row: Dictionary = item + var id: Variant = row.get("id", null) + if id == null or not id is String or (id as String).is_empty(): + continue + out[id as String] = { + "displayName": str(row.get("displayName", "")), + "inventorySlotKind": str(row.get("inventorySlotKind", "")), + } + return out + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _on_request_completed( + result: int, response_code: int, _headers: PackedByteArray, body: PackedByteArray +) -> void: + _busy = false + if result != HTTPRequest.RESULT_SUCCESS: + push_warning("ItemDefinitionsClient: HTTP failed (result=%s)" % result) + return + if response_code < 200 or response_code >= 300: + push_warning("ItemDefinitionsClient: HTTP %s" % response_code) + return + var text := body.get_string_from_utf8() + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + push_warning("ItemDefinitionsClient: non-JSON body") + return + var root: Dictionary = parsed + if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION: + push_warning("ItemDefinitionsClient: schemaVersion mismatch") + return + _definitions_by_id = build_definitions_map(root) + definitions_ready.emit(_definitions_by_id.duplicate(true)) diff --git a/client/scripts/item_definitions_client.gd.uid b/client/scripts/item_definitions_client.gd.uid new file mode 100644 index 0000000..fce01f8 --- /dev/null +++ b/client/scripts/item_definitions_client.gd.uid @@ -0,0 +1 @@ +uid://bneo72itemdefs01 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 246c82f..3eb7e75 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -74,6 +74,8 @@ var _ability_cast_client: Node = null var _cooldown_state: RefCounted = null var _cooldown_client: Node = null var _cooldown_poll_timer: Timer = null +var _last_inventory_snapshot: Dictionary = {} +var _inventory_error: String = "" @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D @onready var _world: Node3D = $World @@ -88,6 +90,9 @@ var _cooldown_poll_timer: Timer = null @onready var _target_lock_label: Label = $UICanvas/TargetLockLabel @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 _inventory_client: Node = $InventoryClient +@onready var _item_defs_client: Node = $ItemDefinitionsClient @onready var _target_client: Node = $TargetSelectionClient @onready var _interaction_client: Node = $InteractionRequestClient @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @@ -129,6 +134,7 @@ func _ready() -> void: _authority.call("sync_from_server") _target_client.call("request_sync_from_server") _setup_hotbar_loadout_sync() + _setup_inventory_sync() func _physics_process(_delta: float) -> void: @@ -306,6 +312,114 @@ func _ensure_cooldown_poll_timer() -> void: add_child(_cooldown_poll_timer) +func _setup_inventory_sync() -> void: + # NEO-72: item defs for display names, then inventory GET for bag/equipment HUD. + _apply_authority_http_config_to_client(_item_defs_client) + _apply_authority_http_config_to_client(_inventory_client) + if _item_defs_client.has_signal("definitions_ready"): + _item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready")) + if _inventory_client.has_signal("inventory_received"): + _inventory_client.connect("inventory_received", Callable(self, "_on_inventory_received")) + if _inventory_client.has_signal("inventory_sync_failed"): + _inventory_client.connect("inventory_sync_failed", Callable(self, "_on_inventory_sync_failed")) + _render_inventory_label() + if _item_defs_client.has_method("request_sync_from_server"): + _item_defs_client.call("request_sync_from_server") + if _inventory_client.has_method("request_sync_from_server"): + _inventory_client.call("request_sync_from_server") + + +func _apply_authority_http_config_to_client(client: Node) -> void: + if not is_instance_valid(client) or not is_instance_valid(_authority): + return + var authority_base_url: Variant = _authority.get("base_url") + var authority_player_id: Variant = _authority.get("dev_player_id") + if authority_base_url is String and not (authority_base_url as String).is_empty(): + client.set("base_url", authority_base_url) + if authority_player_id is String and not (authority_player_id as String).is_empty(): + if client.get("dev_player_id") != null: + client.set("dev_player_id", authority_player_id) + + +func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: + if not _inventory_error.is_empty(): + return + if not _last_inventory_snapshot.is_empty(): + _render_inventory_label() + + +func _on_inventory_received(snapshot: Dictionary) -> void: + _inventory_error = "" + _last_inventory_snapshot = snapshot.duplicate(true) + _render_inventory_label() + + +func _on_inventory_sync_failed(reason: String) -> void: + _inventory_error = reason + _last_inventory_snapshot = {} + _render_inventory_label() + + +func _render_inventory_label() -> void: + if not is_instance_valid(_inventory_label): + return + var header := "Inventory: (refresh: I)" + if not _inventory_error.is_empty(): + _inventory_label.text = "%s\nerror — %s" % [header, _inventory_error] + return + if _last_inventory_snapshot.is_empty(): + _inventory_label.text = "%s\nLoading…" % header + return + var lines: PackedStringArray = [header, "Bag:"] + var bag_lines: PackedStringArray = _format_inventory_slot_lines( + _last_inventory_snapshot.get("bagSlots", []), false + ) + if bag_lines.is_empty(): + lines.append(" (empty)") + else: + for line in bag_lines: + lines.append(line) + lines.append("Equipment:") + var equip_lines: PackedStringArray = _format_inventory_slot_lines( + _last_inventory_snapshot.get("equipmentSlots", []), true + ) + if equip_lines.is_empty(): + lines.append(" slot 0: — empty") + else: + for line in equip_lines: + lines.append(line) + _inventory_label.text = "\n".join(lines) + + +func _format_inventory_slot_lines(raw_slots: Variant, always_show_empty_stub: bool) -> PackedStringArray: + var out: PackedStringArray = PackedStringArray() + if raw_slots == null or not raw_slots is Array: + if always_show_empty_stub: + out.append(" slot 0: — empty") + return out + for slot_variant in raw_slots as Array: + if not slot_variant is Dictionary: + continue + var slot: Dictionary = slot_variant + var slot_index: int = int(slot.get("slotIndex", -1)) + var quantity: int = int(slot.get("quantity", 0)) + if quantity <= 0: + if always_show_empty_stub and slot_index == 0: + out.append(" slot 0: — empty") + continue + var item_id: String = str(slot.get("itemId", "")) + var label: String = item_id + if is_instance_valid(_item_defs_client) and _item_defs_client.has_method("display_name_for"): + label = str(_item_defs_client.call("display_name_for", item_id)) + out.append(" slot %d: %s x%d" % [slot_index, label, quantity]) + return out + + +func _request_inventory_refresh() -> void: + if is_instance_valid(_inventory_client) and _inventory_client.has_method("request_sync_from_server"): + _inventory_client.call("request_sync_from_server") + + func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void: if ( is_instance_valid(_cooldown_client) @@ -440,6 +554,9 @@ func _unhandled_key_input(event: InputEvent) -> void: if event.is_action_pressed("interact_secondary"): _forward_interact_post("post_interact_resource") return + if event.is_action_pressed("inventory_refresh"): + _request_inventory_refresh() + return if event is InputEventKey: var k := event as InputEventKey if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E): @@ -448,6 +565,9 @@ func _unhandled_key_input(event: InputEvent) -> void: if k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R): _forward_interact_post("post_interact_resource") return + if k.pressed and not k.echo and (k.physical_keycode == KEY_I or k.keycode == KEY_I): + _request_inventory_refresh() + return if not event.is_action_pressed("dev_toggle_occluder_obstacle"): return call_deferred("_dev_toggle_obstacle_smoke_deferred") diff --git a/client/test/inventory_client_test.gd b/client/test/inventory_client_test.gd new file mode 100644 index 0000000..ddc8f3d --- /dev/null +++ b/client/test/inventory_client_test.gd @@ -0,0 +1,99 @@ +extends GdUnitTestSuite + +## NEO-72: `InventoryClient` GET parse + failure signals. + +const InventoryClient := preload("res://scripts/inventory_client.gd") + + +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 _empty_inventory_json() -> String: + var bag_parts: PackedStringArray = PackedStringArray() + for i in range(24): + bag_parts.append('{"slotIndex":%d,"quantity":0}' % i) + var bag: String = ", ".join(bag_parts) + return ( + '{"schemaVersion":1,"playerId":"dev-local-1","bagSlots":[%s],' + + '"equipmentSlots":[{"slotIndex":0,"quantity":0}]}' + ) % bag + + +func _make_client(transport: Node) -> Node: + var c: Node = InventoryClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_inventory_json_empty_grid() -> void: + # Arrange + var json := _empty_inventory_json() + # Act + var snapshot: Variant = InventoryClient.parse_inventory_json(json) + # Assert + assert_that(snapshot is Dictionary).is_true() + var bag: Variant = (snapshot as Dictionary).get("bagSlots", null) + assert_that(bag is Array).is_true() + assert_that((bag as Array).size()).is_equal(24) + + +func test_request_sync_emits_inventory_received() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = _empty_inventory_json() + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + assert_signal(c).is_emitted("inventory_received") + assert_that(transport.last_url).contains("/inventory") + + +func test_http_404_emits_inventory_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("inventory_sync_failed", "HTTP 404 (player unknown)") + + +func test_malformed_json_emits_inventory_sync_failed() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = "[]" + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + assert_signal(c).is_emitted("inventory_sync_failed") diff --git a/client/test/item_definitions_client_test.gd b/client/test/item_definitions_client_test.gd new file mode 100644 index 0000000..a075124 --- /dev/null +++ b/client/test/item_definitions_client_test.gd @@ -0,0 +1,127 @@ +extends GdUnitTestSuite + +## NEO-72: `ItemDefinitionsClient` parses world item defs and resolves display names. + +const ItemDefsClient := preload("res://scripts/item_definitions_client.gd") + + +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 = "" + var _queue: Array[Dictionary] = [] + + func enqueue(result: int, code: int, body: String) -> void: + _queue.append({"result": result, "code": code, "body": body}) + + func request( + url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + last_url = url + if _queue.is_empty(): + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + return OK + var r: Dictionary = _queue.pop_front() + request_completed.emit( + r["result"], r["code"], PackedStringArray(), str(r["body"]).to_utf8_buffer() + ) + return OK + + +func _make_client(transport: Node) -> Node: + var c: Node = ItemDefsClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_build_definitions_map_extracts_display_name() -> void: + # Arrange + var root := { + "schemaVersion": 1, + "items": + [ + { + "id": "scrap_metal_bulk", + "displayName": "Scrap Metal (Bulk)", + "inventorySlotKind": "bag", + } + ], + } + # Act + var built: Dictionary = ItemDefsClient.build_definitions_map(root) + # Assert + assert_that(built.has("scrap_metal_bulk")).is_true() + assert_that(built["scrap_metal_bulk"]["displayName"]).is_equal("Scrap Metal (Bulk)") + + +func test_parse_mock_transport_json_body() -> void: + # Arrange + var json := ( + '{"schemaVersion":1,"items":[{"id":"scrap_metal_bulk",' + + '"displayName":"Scrap Metal (Bulk)","inventorySlotKind":"bag"}]}' + ) + # Act + var parsed: Variant = JSON.parse_string(json) + var built: Dictionary = ItemDefsClient.build_definitions_map(parsed) + # Assert + assert_that(built.has("scrap_metal_bulk")).is_true() + + +func test_handler_body_bytes_parse_builds_map() -> void: + # Arrange + var body_json := ( + '{"schemaVersion":1,"items":[{"id":"scrap_metal_bulk",' + + '"displayName":"Scrap Metal (Bulk)","inventorySlotKind":"bag"}]}' + ) + var body_bytes: PackedByteArray = body_json.to_utf8_buffer() + # Act + var text: String = body_bytes.get_string_from_utf8() + var parsed: Variant = JSON.parse_string(text) + var built: Dictionary = ItemDefsClient.build_definitions_map(parsed) + # Assert + assert_that(int((parsed as Dictionary).get("schemaVersion", -1))).is_equal(1) + assert_that(built.has("scrap_metal_bulk")).is_true() + + +func test_request_sync_hits_definitions_endpoint_and_emits() -> void: + # Arrange + var transport := MockHttpTransport.new() + var body_json := ( + '{"schemaVersion":1,"items":[{"id":"scrap_metal_bulk",' + + '"displayName":"Scrap Metal (Bulk)","inventorySlotKind":"bag"}]}' + ) + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, body_json) + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + assert_that(transport.last_url).contains("/game/world/item-definitions") + assert_signal(c).is_emitted("definitions_ready") + + +func test_http_error_does_not_emit_definitions_ready() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 500, "{}") + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + assert_signal(c).wait_until(400).is_not_emitted("definitions_ready") diff --git a/docs/manual-qa/NEO-72.md b/docs/manual-qa/NEO-72.md new file mode 100644 index 0000000..595134c --- /dev/null +++ b/docs/manual-qa/NEO-72.md @@ -0,0 +1,34 @@ +# NEO-72 — Manual QA checklist + +| Field | Value | +|-------|-------| +| Key | NEO-72 | +| Title | E3.M3: Client inventory snapshot HUD (E3S5-01) | +| Linear | https://linear.app/neon-sprawl/issue/NEO-72/e3m3-client-inventory-snapshot-hud-e3s5-01 | +| Plan | `docs/plans/NEO-72-implementation-plan.md` | +| Branch | `NEO-72-client-inventory-snapshot-hud` | + +## Preconditions + +- Server running with default dev player (`dev-local-1`) and item catalog loaded (NEO-51–NEO-55 on `main`). + +## Checklist + +1. Start server: `cd server/NeonSprawl.Server && dotnet run`. +2. Run Godot main scene (**F5**). **`InventoryLabel`** (below cooldown HUD) shows **`Inventory: (refresh: I)`**, **`Bag: (empty)`**, and **`Equipment:`** / **`slot 0: — empty`** on a fresh dev player. +3. Seed inventory via Bruno or curl: + + ```bash + curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/inventory" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"mutationKind":"add","itemId":"scrap_metal_bulk","quantity":5}' + ``` + +4. Press **`I`** (or **`inventory_refresh`**). HUD lists occupied bag slot with **`Scrap Metal (Bulk) x5`** (display name from NEO-53 defs). +5. Stop the server; press **`I`**. HUD shows **`error — …`** and Output shows **`InventoryClient:`** **`push_warning`**. +6. Restart server; press **`I`** again — HUD returns to synced inventory lines. + +## Notes + +- **`I`** is handled in **`main.gd`** **`_unhandled_key_input`** (same reliability pattern as **E** / **R** interact keys). +- POST inventory mutations from UI are out of scope; use Bruno/curl to seed stock for this story.