diff --git a/client/README.md b/client/README.md index ab3af26..94353dc 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)`. @@ -193,7 +203,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c **CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that. -**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. +**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. **Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. 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/hotbar_cast_slot_resolver.gd.uid b/client/scripts/hotbar_cast_slot_resolver.gd.uid new file mode 100644 index 0000000..de4302f --- /dev/null +++ b/client/scripts/hotbar_cast_slot_resolver.gd.uid @@ -0,0 +1 @@ +uid://d14jp7qtpqpfj diff --git a/client/scripts/inventory_client.gd b/client/scripts/inventory_client.gd new file mode 100644 index 0000000..6dd5984 --- /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: PackedStringArray, 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..e70a04c --- /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). +## 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: PackedStringArray, 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..fd986d6 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,126 @@ 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 = _inventory_item_label(item_id) + out.append(" slot %d: %s x%d" % [slot_index, label, quantity]) + return out + + +func _inventory_item_label(item_id: String) -> String: + if not is_instance_valid(_item_defs_client): + return item_id + if not _item_defs_client.has_method("display_name_for"): + return item_id + return str(_item_defs_client.call("display_name_for", item_id)) + + +func _request_inventory_refresh() -> void: + if not is_instance_valid(_inventory_client): + return + if _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) @@ -427,32 +553,65 @@ func _on_move_rejected(reason_code: String) -> void: ## **F9** / **F10** are debugger shortcuts in the embedded game; use the Input Map action. ## For a true **freed-node** occluder test, reload the scene or remove the body in the editor. func _unhandled_key_input(event: InputEvent) -> void: - for slot_digit in range(1, 9): - var action_name := "hotbar_slot_%d" % slot_digit - if event.is_action_pressed(action_name): - _request_hotbar_cast_slot(slot_digit - 1) - return - # NEO-25: route interact keys here — `InteractionRequestClient._input` is unreliable in the - # embedded Game dock / focus order; `_unhandled_key_input` on the scene root still fires. - if event.is_action_pressed("interact"): - _forward_interact_post("post_interact_terminal") + if _try_route_gameplay_key_input(event): return - if event.is_action_pressed("interact_secondary"): - _forward_interact_post("post_interact_resource") - 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): - _forward_interact_post("post_interact_terminal") - return - 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 not event.is_action_pressed("dev_toggle_occluder_obstacle"): return call_deferred("_dev_toggle_obstacle_smoke_deferred") +## NEO-25 / NEO-72: route interact + inventory keys here — `InteractionRequestClient._input` +## is unreliable in the embedded Game dock; `_unhandled_key_input` on the scene root still fires. +func _try_route_gameplay_key_input(event: InputEvent) -> bool: + var hotbar_slot := _hotbar_slot_index_from_event(event) + if hotbar_slot >= 0: + _request_hotbar_cast_slot(hotbar_slot) + return true + if _try_interact_key_input(event): + return true + if _try_inventory_refresh_input(event): + return true + return false + + +func _try_interact_key_input(event: InputEvent) -> bool: + if event.is_action_pressed("interact"): + _forward_interact_post("post_interact_terminal") + return true + if event.is_action_pressed("interact_secondary"): + _forward_interact_post("post_interact_resource") + return true + if event is InputEventKey: + var k := event as InputEventKey + if k.pressed and not k.echo: + if k.physical_keycode == KEY_E or k.keycode == KEY_E: + _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") + return true + return false + + +func _hotbar_slot_index_from_event(event: InputEvent) -> int: + for slot_digit in range(1, 9): + if event.is_action_pressed("hotbar_slot_%d" % slot_digit): + return slot_digit - 1 + return -1 + + +func _try_inventory_refresh_input(event: InputEvent) -> bool: + if event.is_action_pressed("inventory_refresh"): + _request_inventory_refresh() + return true + if event is InputEventKey: + var k := event as InputEventKey + if k.pressed and not k.echo and (k.physical_keycode == KEY_I or k.keycode == KEY_I): + _request_inventory_refresh() + return true + return false + + func _request_hotbar_cast_slot(slot_index: int) -> void: if not is_instance_valid(_hotbar_state) or not _hotbar_state.has_method("slots_snapshot"): return diff --git a/client/test/hotbar_cast_slot_resolver_test.gd.uid b/client/test/hotbar_cast_slot_resolver_test.gd.uid new file mode 100644 index 0000000..d31d7d6 --- /dev/null +++ b/client/test/hotbar_cast_slot_resolver_test.gd.uid @@ -0,0 +1 @@ +uid://ii06cl13tfkv diff --git a/client/test/inventory_client_test.gd b/client/test/inventory_client_test.gd new file mode 100644 index 0000000..2b0820a --- /dev/null +++ b/client/test/inventory_client_test.gd @@ -0,0 +1,113 @@ +extends GdUnitTestSuite + +## NEO-72: `InventoryClient` GET parse + failure signals. + +const InventoryClient := preload("res://scripts/inventory_client.gd") + +var _inventory_capture: Dictionary = {} + + +func _capture_inventory(snapshot: Dictionary) -> void: + _inventory_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 _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 + _inventory_capture = {} + var transport := MockHttpTransport.new() + transport.body_json = _empty_inventory_json() + var c := _make_client(transport) + c.connect("inventory_received", Callable(self, "_capture_inventory")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + assert_signal(c).is_emitted("inventory_received") + assert_that(_inventory_capture.has("bagSlots")).is_true() + assert_that((_inventory_capture["bagSlots"] as Array).size()).is_equal(24) + assert_that((_inventory_capture["equipmentSlots"] as Array).size()).is_equal(1) + 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/inventory_client_test.gd.uid b/client/test/inventory_client_test.gd.uid new file mode 100644 index 0000000..c008a43 --- /dev/null +++ b/client/test/inventory_client_test.gd.uid @@ -0,0 +1 @@ +uid://cneo72invtest01 diff --git a/client/test/item_definitions_client_test.gd b/client/test/item_definitions_client_test.gd new file mode 100644 index 0000000..cea7b47 --- /dev/null +++ b/client/test/item_definitions_client_test.gd @@ -0,0 +1,110 @@ +extends GdUnitTestSuite + +## NEO-72: `ItemDefinitionsClient` parses world item defs and resolves display names. + +const ItemDefsClient := preload("res://scripts/item_definitions_client.gd") + +const _SAMPLE_BODY_JSON := ( + '{"schemaVersion":1,"items":[{"id":"scrap_metal_bulk",' + + '"displayName":"Scrap Metal (Bulk)","inventorySlotKind":"bag"}]}' +) + + +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_request_sync_hits_definitions_endpoint_and_emits() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _SAMPLE_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_display_name_for_after_request_sync() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _SAMPLE_BODY_JSON) + var c := _make_client(transport) + # Act + c.call("request_sync_from_server") + # Assert + assert_that(c.call("display_name_for", "scrap_metal_bulk")).is_equal("Scrap Metal (Bulk)") + + +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/client/test/item_definitions_client_test.gd.uid b/client/test/item_definitions_client_test.gd.uid new file mode 100644 index 0000000..d36e1d2 --- /dev/null +++ b/client/test/item_definitions_client_test.gd.uid @@ -0,0 +1 @@ +uid://cneo72itemdeftest01 diff --git a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md index b823d21..b98a32a 100644 --- a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md +++ b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md @@ -74,7 +74,9 @@ Epic 3 **Slice 1** — MVP inventory; `item_created`, transfer failures. **Inventory telemetry hooks (NEO-56):** comment-only **`item_created`** (successful add) and **`inventory_transfer_denied`** + **`reasonCode`** anchors in **`PlayerInventoryOperations`**; [server README — Player inventory (NEO-56)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http). Plan: [NEO-56 implementation plan](../../plans/NEO-56-implementation-plan.md); manual QA [`NEO-56`](../../manual-qa/NEO-56.md). -**Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks). **Client (Slice 5):** inventory HUD — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-01** [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72). +**Client inventory HUD (NEO-72):** Godot **`inventory_client.gd`** + **`item_definitions_client.gd`** — boot **`GET`** inventory snapshot + cached item defs for **`displayName`**; **`UICanvas/InventoryLabel`**; manual refresh **`I`** ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. + +**Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks). **Client (Slice 5):** inventory HUD landed — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-01** [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72). ## Source anchors diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 9d2d226..2431e48 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -54,7 +54,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/` — `MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) | | E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-44 | | E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **Slice 5 client (planned):** gather feedback HUD [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/` | -| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **Slice 5 client (planned):** inventory HUD [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | +| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **Slice 5 client (remaining):** gather feedback [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73), craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74), capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **Slice 5 client (planned):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74); capstone playable loop [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md) … [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 10ac879..3c80324 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -50,7 +50,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen **E3.M1 note:** Epic 3 **Slice 2** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) → [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64); label **`E3.M1`**. See [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md), [E3_M1_ResourceNodeAndGatherLoop.md](E3_M1_ResourceNodeAndGatherLoop.md). **NEO-57** (content + CI), **NEO-58** (server fail-fast load), **NEO-59** (`IResourceNodeDefinitionRegistry` + DI), **NEO-60** (`GET /game/world/resource-node-definitions`), **NEO-61** (`IResourceNodeInstanceStore` + depletion operations, `V006`), **NEO-62** (`GatherOperations` + `GatherResult`), **NEO-63** (interact → gather engine + four registry anchors + Bruno), **NEO-64** (comment-only **`resource_gathered`** / **`gather_node_depleted`** telemetry hook sites in `GatherOperations.TryGather`) — E3M1-01–**E3M1-08** complete; register row **Ready**. -**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), **NEO-54** (inventory store + stack/slot rules engine), **NEO-55** (`GET`/`POST /game/players/{id}/inventory`), and **NEO-56** (comment-only `item_created` / `inventory_transfer_denied` telemetry hook sites in `PlayerInventoryOperations`) — Epic 3 Slice 1 backlog **E3M3-01**–**E3M3-07** complete; register row **In Progress** until Slice 2+; later slices update the alignment table as they land. +**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), **NEO-54** (inventory store + stack/slot rules engine), **NEO-55** (`GET`/`POST /game/players/{id}/inventory`), and **NEO-56** (comment-only `item_created` / `inventory_transfer_denied` telemetry hook sites in `PlayerInventoryOperations`) — Epic 3 Slice 1 backlog **E3M3-01**–**E3M3-07** complete; **NEO-72** (client inventory HUD — `inventory_client.gd`, `item_definitions_client.gd`, `InventoryLabel`; [`NEO-72` manual QA](../../manual-qa/NEO-72.md)) — Epic 3 Slice 5 **E3S5-01** client landed; register row **In Progress** until Slice 2+; later slices update the alignment table as they land. **E3.M2 note:** Epic 3 **Slice 3** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) → [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71); label **`E3.M2`**. See [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md), [E3_M2_RefinementAndRecipeExecution.md](E3_M2_RefinementAndRecipeExecution.md). **NEO-42** (refine XP prep helper) is **Done**; **NEO-65**–**NEO-71** complete — **NEO-71** (comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in `CraftOperations.TryCraft`) — E3M2-01–**E3M2-07** complete; register row **Ready**. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. 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. diff --git a/docs/plans/E3S5-client-prototype-backlog.md b/docs/plans/E3S5-client-prototype-backlog.md index 2a536ba..5741357 100644 --- a/docs/plans/E3S5-client-prototype-backlog.md +++ b/docs/plans/E3S5-client-prototype-backlog.md @@ -76,9 +76,9 @@ Working backlog for **Epic 3 — Slice 5** ([client economy integration](../deco **Acceptance criteria** -- [ ] Boot (or first open) shows current server inventory for `dev_player_id`. -- [ ] HUD lists all non-empty bag slots and equipment stub slot from GET body. -- [ ] Failed GET surfaces visible error/warning (match interaction client pattern). +- [x] Boot (or first open) shows current server inventory for `dev_player_id`. +- [x] HUD lists all non-empty bag slots and equipment stub slot from GET body. +- [x] Failed GET surfaces visible error/warning (match interaction client pattern). --- diff --git a/docs/plans/NEO-72-implementation-plan.md b/docs/plans/NEO-72-implementation-plan.md new file mode 100644 index 0000000..633b328 --- /dev/null +++ b/docs/plans/NEO-72-implementation-plan.md @@ -0,0 +1,144 @@ +# NEO-72 — Implementation plan + +## Story reference + +| 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 | +| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 **Slice 5** (client 1 of 4) · backlog **E3S5-01** | +| **Branch** | `NEO-72-client-inventory-snapshot-hud` | +| **Server deps** | [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55) — `GET /game/players/{id}/inventory` (**Done**); [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) — `GET /game/world/item-definitions` (**Done**) | +| **Pattern** | [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24) / [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — thin HTTP client nodes, `main.gd` wiring, debug HUD labels, GdUnit mock transport | +| **Downstream** | [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) gather feedback calls inventory refresh; [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) craft UI reuses inventory + defs clients | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Display names** | Resolve **`displayName`** via **`GET /game/world/item-definitions`** or raw **`itemId`** only? | **Include `item_definitions_client`** + cached lookup — NEO-53 is landed; readable labels help gather/craft QA in NEO-73/74. | **User:** include defs + **`displayName`**. | +| **Failed GET UX** | How to surface inventory GET errors? | **`InventoryLabel` error line + `push_warning`** — visible in-game (NEO-28 cast deny HUD) and dev console (cooldown/interaction HTTP clients). | **User:** HUD + **`push_warning`**. | +| **Refresh key** | Manual refresh binding? | **Bind `I` → `inventory_refresh`** — E3S5 backlog QA note; public **`request_sync_from_server()`** entrypoint for NEO-73. | **User:** **`I`** key. | + +## Goal, scope, and out-of-scope + +**Goal:** Godot client for **`GET /game/players/{id}/inventory`** plus a readable bag/equipment debug HUD; boot hydrate and a manual refresh entrypoint for later Slice 5 stories. + +**In scope (from Linear + [E3S5-01](E3S5-client-prototype-backlog.md#e3s5-01--client-inventory-snapshot-hud-e3m3)):** + +- **`inventory_client.gd`:** GET snapshot, parse v1 envelope, emit signal with parsed body; public **`request_sync_from_server()`**; structured **`push_warning`** on transport/HTTP/JSON failures. +- **`item_definitions_client.gd`:** GET world item defs once (or on boot), cache **`id → displayName`** (and optional **`inventorySlotKind`** for row prefix); emit **`definitions_ready`**. +- **`InventoryLabel`** under **`UICanvas`:** lists **non-empty bag slots** + **equipment slot 0** (show **`— empty`** when quantity 0); resolve labels via defs cache when available, fall back to raw **`itemId`**. +- Boot hydrate after scene ready (chain: item defs → inventory GET, mirroring hotbar → cooldown boot order in NEO-32). +- **`I`** input → **`inventory_refresh`** in **`project.godot`**; handled in **`main.gd`** **`_unhandled_key_input`** (same pattern as E/R interact forwarding). +- GdUnit: mock HTTP — happy-path parse + error paths for both clients. +- **`client/README.md`** inventory HUD subsection. +- **`docs/manual-qa/NEO-72.md`**. + +**Out of scope (from Linear):** + +- Drag-drop, stack split, equip UX polish. +- POST inventory mutations from UI (gather/craft stories own refresh triggers). +- Server route or DTO changes. + +## Acceptance criteria checklist + +- [x] Boot shows current server inventory for **`dev-local-1`** (empty bag + empty equipment stub on fresh dev player). +- [x] HUD lists all **non-empty** **`bagSlots`** and **equipment slot 0** from GET body (with **`displayName`** when defs loaded). +- [x] Failed GET surfaces visible error on **`InventoryLabel`** and **`push_warning`** in Output. +- [x] **`I`** triggers manual inventory refresh; **`request_sync_from_server()`** callable for NEO-73 wiring. + +## Technical approach + +1. **`item_definitions_client.gd`** + - Injectable HTTP transport; **`base_url`** / shared authority config from **`main.gd`** (same as **`CooldownSnapshotClient`**). + - **`request_sync_from_server()`** → **`GET …/game/world/item-definitions`**. + - On 200 + v1 JSON: build **`Dictionary`** map **`itemId → { displayName, inventorySlotKind }`**; emit **`definitions_ready(map)`**. + - Errors: **`push_warning("ItemDefinitionsClient: …")`**; emit nothing (inventory HUD falls back to raw ids). + +2. **`inventory_client.gd`** + - **`request_sync_from_server()`** → **`GET …/game/players/{dev_player_id}/inventory`**. + - On 200: validate **`schemaVersion` 1**, emit **`inventory_received(snapshot: Dictionary)`** with full parsed body. + - On failure (transport, non-2xx, non-JSON): **`push_warning("InventoryClient: …")`** + emit **`inventory_sync_failed(reason: String)`** so **`main.gd`** can paint the HUD error line without parsing a snapshot. + - **`_busy`** guard like **`CooldownSnapshotClient`** (ignore overlapping GETs). + +3. **Boot sequence in `main.gd`** + - Add **`_setup_inventory_sync()`** from **`_ready()`** after authority is wired. + - Instantiate **`ItemDefinitionsClient`** + **`InventoryClient`** as child nodes; copy **`base_url`** / **`dev_player_id`** from **`PositionAuthorityClient`**. + - Connect **`definitions_ready`** → cache map on **`main`** (or hold ref to defs client with **`display_name_for(item_id)`** helper method). + - On boot: **`item_definitions_client.request_sync_from_server()`** then **`inventory_client.request_sync_from_server()`** (defs may still be in flight when first inventory paints — re-render label when defs arrive). + - Connect **`inventory_received`** / **`inventory_sync_failed`** → **`_render_inventory_label()`**. + +4. **HUD formatting (`_render_inventory_label`)** + - Header: **`Inventory:`** + optional **`(refresh: I)`** hint for QA. + - **Bag:** iterate **`bagSlots`**; emit lines only where **`quantity > 0`**: **`slot {slotIndex}: {displayName or itemId} x{quantity}`**. + - **Equipment:** always show **`slot 0`**: item line if occupied, else **`slot 0: — empty`**. + - On **`inventory_sync_failed`**: replace body with **`Inventory: error — {reason}`** (keep header); also **`push_warning`** already fired from client. + +5. **Scene + input** + - **`main.tscn`:** add **`InventoryClient`**, **`ItemDefinitionsClient`** nodes; **`InventoryLabel`** below **`CooldownSlotsLabel`** (offset ~460px top, autowrap, same font theme as sibling HUD labels). + - **`project.godot`:** **`inventory_refresh`** → **I** (physical key). + - **`main.gd` `_unhandled_key_input`:** if **`inventory_refresh`** pressed → **`inventory_client.request_sync_from_server()`**. + +6. **Public refresh contract for NEO-73** + - Expose **`InventoryClient.request_sync_from_server()`** (no wrapper required in **`main`** unless we add **`refresh_inventory_from_server()`** alias — prefer direct client call from **`main`** helpers NEO-73 can reuse). + +7. **Docs on land** + - Update [E3_M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E3.M3 row when implementation completes. + - **`E3S5-client-prototype-backlog.md`:** check E3S5-01 boxes. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-72-implementation-plan.md` | This plan. | +| `client/scripts/item_definitions_client.gd` | GET world item defs; cache id → display metadata; **`definitions_ready`** signal. | +| `client/scripts/item_definitions_client.gd.uid` | Godot uid companion (tracked). | +| `client/scripts/inventory_client.gd` | GET player inventory snapshot; **`inventory_received`** / **`inventory_sync_failed`** signals. | +| `client/scripts/inventory_client.gd.uid` | Godot uid companion (tracked). | +| `client/test/item_definitions_client_test.gd` | GdUnit: parse v1 items list; lookup helper; HTTP error **`push_warning`**. | +| `client/test/inventory_client_test.gd` | GdUnit: parse bag/equipment slots; 404/500 failure emits **`inventory_sync_failed`**. | +| `docs/manual-qa/NEO-72.md` | Boot empty inventory, Bruno seed + refresh, failed GET (server stopped), **`I`** refresh. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/scenes/main.tscn` | Add **`InventoryClient`**, **`ItemDefinitionsClient`**, **`InventoryLabel`** nodes. | +| `client/scripts/main.gd` | Boot wiring, HUD render, **`I`** refresh input, defs cache + inventory signal handlers. | +| `client/project.godot` | Register **`inventory_refresh`** input action (**I**). | +| `client/README.md` | Inventory HUD subsection: bindings, label format, boot/refresh behavior, server deps. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `client/test/item_definitions_client_test.gd` | Mock transport 200 with six-item v1 JSON → map contains **`scrap_metal_bulk`** **`displayName`**; non-200 → no **`definitions_ready`**, warning path. AAA layout. | +| `client/test/inventory_client_test.gd` | Mock 200 with empty 24+1 slot grid → **`inventory_received`** payload has **`bagSlots.size()` 24**; 404 → **`inventory_sync_failed`** + warning; malformed JSON → failure signal. AAA layout. | + +No new **C#** tests (client-only; server contracts covered by NEO-55/NEO-53). No new Bruno (server already exercised). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Defs/inventory race on boot** | First inventory paint may use raw ids; re-render on **`definitions_ready`** — acceptable for prototype. | **adopted** | +| **Empty bag UX** | Show **`Bag: (empty)`** when no occupied bag slots; equipment stub always listed. | **adopted** | +| **HUD clutter at 24 slots** | Non-empty-only rows keep label readable until NEO-75 optional collapse. | **adopted** | +| **Slice 5 module docs** | Update alignment register when NEO-72 lands, not at kickoff. | **deferred** (implementation) | + +## Decisions (kickoff) + +- **Include `item_definitions_client`** with **`displayName`** resolution (user confirmed). +- **Error UX:** **`InventoryLabel`** error line + **`push_warning`** (user confirmed). +- **Manual refresh:** **`I` → `inventory_refresh`** (user confirmed). +- **Equipment row:** always show slot **0** (empty or occupied) per Linear AC “equipment stub slot”. +- **No POST inventory** from UI in this story. + +## Implementation reconciliation (shipped) + +- **Clients:** `item_definitions_client.gd` (cached **`displayName`**), `inventory_client.gd` (**`inventory_received`** / **`inventory_sync_failed`**). +- **HUD:** `UICanvas/InventoryLabel`; boot hydrate; **`I` → `inventory_refresh`**; error line + **`push_warning`** on failed GET. +- **Tests:** `client/test/item_definitions_client_test.gd`, `client/test/inventory_client_test.gd`. +- **Docs:** `client/README.md` inventory section; [`NEO-72` manual QA](../manual-qa/NEO-72.md); E3.M3 module + alignment register updated. diff --git a/docs/reviews/2026-05-24-NEO-72.md b/docs/reviews/2026-05-24-NEO-72.md new file mode 100644 index 0000000..622dc69 --- /dev/null +++ b/docs/reviews/2026-05-24-NEO-72.md @@ -0,0 +1,64 @@ +# Code review — NEO-72 client inventory snapshot HUD + +**Date:** 2026-05-24 +**Scope:** Branch `NEO-72-client-inventory-snapshot-hud` · commits `1273296`–`e7a61a1` vs `origin/main` +**Base:** `origin/main` +**Follow-up:** Review feedback addressed in `e7a61a1` (header types, tests, docs). + +## Verdict + +**Approve with nits** — blocking header-type bug fixed; integration tests now exercise handler payloads; remaining nits are deferred (HTTP 500 test, unused `inventorySlotKind` in HUD). + +## Summary + +The branch adds **`inventory_client.gd`** and **`item_definitions_client.gd`**, boot wiring and **`InventoryLabel`** rendering in **`main.gd`**, **`I` → `inventory_refresh`**, GdUnit suites, manual QA, and module/alignment doc updates. The design follows NEO-32/NEO-24 patterns (thin clients, `_busy` guard, authority config copy, HUD error line + **`push_warning`**). Documentation is thorough and matches the adopted kickoff decisions (defs cache, display names, manual refresh). Follow-up commit **`e7a61a1`** corrects **`request_completed`** header typing so handlers run under real **`HTTPRequest`** and mock transports. + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-72-implementation-plan.md`](../plans/NEO-72-implementation-plan.md) | **Matches** — acceptance checklist complete; reconciliation section present; kickoff decisions reflected in code. | +| [`docs/plans/E3S5-client-prototype-backlog.md`](../plans/E3S5-client-prototype-backlog.md) · **E3S5-01** | **Matches** — acceptance criteria checked; scope/out-of-scope honored. | +| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — NEO-72 client HUD landed note added. | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row notes NEO-72; Slice 5 remaining stories cited. | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M3 note now includes NEO-72 client HUD (`e7a61a1`). | +| [`docs/manual-qa/NEO-72.md`](../manual-qa/NEO-72.md) | **Matches** — boot empty, seed + refresh, server-down error, **`I`** refresh. | +| [`client/README.md`](../../client/README.md) inventory HUD section | **Matches** — endpoints, bindings, refresh contract for NEO-73; automated test scope updated (`e7a61a1`). | +| Full-stack epic decomposition | **Matches** — this is the E3S5-01 **client** counterpart; Godot manual QA present; does not claim Slice 5 capstone complete. | + +## Blocking issues + +1. ~~**`request_completed` header parameter type** — In `client/scripts/inventory_client.gd` and `client/scripts/item_definitions_client.gd`, `_on_request_completed` declares `_headers: PackedByteArray`. Every other HTTP client in `client/scripts/` uses `_headers: PackedStringArray` (e.g. `cooldown_snapshot_client.gd`, `hotbar_loadout_client.gd`, `position_authority_client.gd`). Godot **`HTTPRequest.request_completed`** emits **`PackedStringArray`** headers. GdUnit mock transports match that. Running the new suites logs: `ERROR: Error calling from signal 'request_completed' to callable: … Cannot convert argument 3 from PackedStringArray to PackedByteArray.` The handler never runs, so **`inventory_received`** / **`definitions_ready`** do not fire and the HUD stays on **Loading…** (or never refreshes display names). **Fix:** change both handlers to `_headers: PackedStringArray` (body stays **`PackedByteArray`**).~~ **Done.** `e7a61a1` — both clients use **`PackedStringArray`**; GdUnit suites pass with no conversion errors. + +## Suggestions + +1. ~~**Re-run integration tests after header fix** — Confirm signal assertions still pass *and* Godot Output has no `request_completed` conversion errors. Consider asserting payload contents in `test_request_sync_emits_inventory_received` (e.g. `bagSlots.size() == 24`) so a silent handler failure cannot pass.~~ **Done.** `e7a61a1` — inventory test captures snapshot and asserts 24 bag + 1 equipment slots. + +2. ~~**`display_name_for` coverage** — Add one GdUnit test that loads defs via mock transport then calls **`display_name_for("scrap_metal_bulk")`** on the client instance (plan implied lookup helper coverage; only static **`build_definitions_map`** is exercised today).~~ **Done.** `e7a61a1` — `test_display_name_for_after_request_sync`. + +3. ~~**`module_dependency_register.md` E3.M3 note** — Append a NEO-72 client HUD landed clause to match the alignment register row (non-blocking doc parity).~~ **Done.** `e7a61a1`. + +4. ~~**`client/README.md` automated tests scope** — The “Scope” bullet list under **Automated tests** still omits the new inventory/defs clients; add them when convenient so discoverability matches NEO-72.~~ **Done.** `e7a61a1`. + +## Nits + +- ~~Nit: `item_definitions_client_test.gd` has overlapping parse tests (`test_parse_mock_transport_json_body`, `test_handler_body_bytes_parse_builds_map`) that duplicate **`test_build_definitions_map_extracts_display_name`**; consolidate when touching tests for the header fix.~~ **Done.** `e7a61a1` — removed duplicate parse tests. +- Nit: Plan tests table mentions HTTP **500** for **`inventory_sync_failed`**; only **404** is covered — acceptable for prototype once happy + 404 + malformed JSON paths work. **Deferred** — 404 + malformed JSON sufficient for merge. +- Nit: **`inventorySlotKind`** is cached but unused in HUD formatting (plan listed it as optional row prefix — fine to defer). **Deferred.** + +## Verification + +After fixing header types: + +```bash +cd client +godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \ + -a res://test/inventory_client_test.gd -a res://test/item_definitions_client_test.gd +``` + +Confirm **no** `Cannot convert argument 3 from PackedStringArray to PackedByteArray` in output. **Verified** on `e7a61a1`. + +Manual (required before merge — validates real **`HTTPRequest`** path): + +1. `cd server/NeonSprawl.Server && dotnet run` +2. Godot **F5** — follow [`docs/manual-qa/NEO-72.md`](../manual-qa/NEO-72.md) (boot empty bag, curl seed, **`I`** refresh with display name, server stopped → HUD error).