Merge pull request #106 from ViPro-Technologies/NEO-72-client-inventory-snapshot-hud

NEO-72: Client inventory snapshot HUD (E3S5-01)
pull/109/head
VinPropane 2026-05-24 18:58:25 -04:00 committed by GitHub
commit cdde994c7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 881 additions and 27 deletions

View File

@ -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.

View File

@ -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]

View File

@ -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…"

View File

@ -0,0 +1 @@
uid://d14jp7qtpqpfj

View File

@ -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)

View File

@ -0,0 +1 @@
uid://bneo72inventory01

View File

@ -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))

View File

@ -0,0 +1 @@
uid://bneo72itemdefs01

View File

@ -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

View File

@ -0,0 +1 @@
uid://ii06cl13tfkv

View File

@ -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")

View File

@ -0,0 +1 @@
uid://cneo72invtest01

View File

@ -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")

View File

@ -0,0 +1 @@
uid://cneo72itemdeftest01

View File

@ -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

View File

@ -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-37NEO-41, NEO-42NEO-44 |
| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57NEO-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) |
---

View File

@ -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.

View File

@ -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-51NEO-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.

View File

@ -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).
---

View File

@ -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.

View File

@ -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).