NEO-72: add client inventory snapshot HUD and HTTP clients.
Wire GET inventory + item-definitions into InventoryLabel with I-key refresh, displayName labels, and GdUnit coverage for happy/error paths.pull/106/head
parent
1273296c73
commit
751fea3db4
|
|
@ -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)`.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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…"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
extends Node
|
||||
|
||||
## NEO-72: prototype HTTP client for [code]GET /game/players/{id}/inventory[/code] (NEO-55).
|
||||
|
||||
signal inventory_received(snapshot: Dictionary)
|
||||
signal inventory_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/players/%s/inventory" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("InventoryClient: %s" % reason)
|
||||
_busy = false
|
||||
inventory_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_inventory_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedByteArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("InventoryClient: %s" % reason)
|
||||
inventory_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("InventoryClient: %s" % reason404)
|
||||
inventory_sync_failed.emit(reason404)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("InventoryClient: %s" % reason_code)
|
||||
inventory_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_inventory_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("InventoryClient: %s" % reason_json)
|
||||
inventory_sync_failed.emit(reason_json)
|
||||
return
|
||||
inventory_received.emit(snapshot as Dictionary)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo72inventory01
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
extends Node
|
||||
|
||||
## NEO-72: fetches [code]GET /game/world/item-definitions[/code] (NEO-53) and caches id → display metadata
|
||||
## for inventory HUD labels.
|
||||
|
||||
signal definitions_ready(definitions_by_id: Dictionary)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _definitions_by_id: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/world/item-definitions" % _base_root()
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
push_warning("ItemDefinitionsClient: GET failed to start (%s)" % err)
|
||||
_busy = false
|
||||
|
||||
|
||||
func display_name_for(item_id: String) -> String:
|
||||
var row: Variant = _definitions_by_id.get(item_id, null)
|
||||
if row is Dictionary:
|
||||
var dn: Variant = (row as Dictionary).get("displayName", "")
|
||||
if dn is String and not (dn as String).is_empty():
|
||||
return dn as String
|
||||
return item_id
|
||||
|
||||
|
||||
func definitions_map() -> Dictionary:
|
||||
return _definitions_by_id.duplicate(true)
|
||||
|
||||
|
||||
static func build_definitions_map(root: Dictionary) -> Dictionary:
|
||||
var out: Dictionary = {}
|
||||
var raw_items: Variant = root.get("items", null)
|
||||
if raw_items == null or not raw_items is Array:
|
||||
return out
|
||||
for item in raw_items as Array:
|
||||
if not item is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = item
|
||||
var id: Variant = row.get("id", null)
|
||||
if id == null or not id is String or (id as String).is_empty():
|
||||
continue
|
||||
out[id as String] = {
|
||||
"displayName": str(row.get("displayName", "")),
|
||||
"inventorySlotKind": str(row.get("inventorySlotKind", "")),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedByteArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
push_warning("ItemDefinitionsClient: HTTP failed (result=%s)" % result)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
push_warning("ItemDefinitionsClient: HTTP %s" % response_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
push_warning("ItemDefinitionsClient: non-JSON body")
|
||||
return
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
push_warning("ItemDefinitionsClient: schemaVersion mismatch")
|
||||
return
|
||||
_definitions_by_id = build_definitions_map(root)
|
||||
definitions_ready.emit(_definitions_by_id.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo72itemdefs01
|
||||
|
|
@ -74,6 +74,8 @@ var _ability_cast_client: Node = null
|
|||
var _cooldown_state: RefCounted = null
|
||||
var _cooldown_client: Node = null
|
||||
var _cooldown_poll_timer: Timer = null
|
||||
var _last_inventory_snapshot: Dictionary = {}
|
||||
var _inventory_error: String = ""
|
||||
|
||||
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
|
||||
@onready var _world: Node3D = $World
|
||||
|
|
@ -88,6 +90,9 @@ var _cooldown_poll_timer: Timer = null
|
|||
@onready var _target_lock_label: Label = $UICanvas/TargetLockLabel
|
||||
@onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel
|
||||
@onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
|
||||
@onready var _inventory_label: Label = $UICanvas/InventoryLabel
|
||||
@onready var _inventory_client: Node = $InventoryClient
|
||||
@onready var _item_defs_client: Node = $ItemDefinitionsClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
@onready var _interaction_client: Node = $InteractionRequestClient
|
||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||
|
|
@ -129,6 +134,7 @@ func _ready() -> void:
|
|||
_authority.call("sync_from_server")
|
||||
_target_client.call("request_sync_from_server")
|
||||
_setup_hotbar_loadout_sync()
|
||||
_setup_inventory_sync()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
|
|
@ -306,6 +312,114 @@ func _ensure_cooldown_poll_timer() -> void:
|
|||
add_child(_cooldown_poll_timer)
|
||||
|
||||
|
||||
func _setup_inventory_sync() -> void:
|
||||
# NEO-72: item defs for display names, then inventory GET for bag/equipment HUD.
|
||||
_apply_authority_http_config_to_client(_item_defs_client)
|
||||
_apply_authority_http_config_to_client(_inventory_client)
|
||||
if _item_defs_client.has_signal("definitions_ready"):
|
||||
_item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready"))
|
||||
if _inventory_client.has_signal("inventory_received"):
|
||||
_inventory_client.connect("inventory_received", Callable(self, "_on_inventory_received"))
|
||||
if _inventory_client.has_signal("inventory_sync_failed"):
|
||||
_inventory_client.connect("inventory_sync_failed", Callable(self, "_on_inventory_sync_failed"))
|
||||
_render_inventory_label()
|
||||
if _item_defs_client.has_method("request_sync_from_server"):
|
||||
_item_defs_client.call("request_sync_from_server")
|
||||
if _inventory_client.has_method("request_sync_from_server"):
|
||||
_inventory_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _apply_authority_http_config_to_client(client: Node) -> void:
|
||||
if not is_instance_valid(client) or not is_instance_valid(_authority):
|
||||
return
|
||||
var authority_base_url: Variant = _authority.get("base_url")
|
||||
var authority_player_id: Variant = _authority.get("dev_player_id")
|
||||
if authority_base_url is String and not (authority_base_url as String).is_empty():
|
||||
client.set("base_url", authority_base_url)
|
||||
if authority_player_id is String and not (authority_player_id as String).is_empty():
|
||||
if client.get("dev_player_id") != null:
|
||||
client.set("dev_player_id", authority_player_id)
|
||||
|
||||
|
||||
func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
|
||||
if not _inventory_error.is_empty():
|
||||
return
|
||||
if not _last_inventory_snapshot.is_empty():
|
||||
_render_inventory_label()
|
||||
|
||||
|
||||
func _on_inventory_received(snapshot: Dictionary) -> void:
|
||||
_inventory_error = ""
|
||||
_last_inventory_snapshot = snapshot.duplicate(true)
|
||||
_render_inventory_label()
|
||||
|
||||
|
||||
func _on_inventory_sync_failed(reason: String) -> void:
|
||||
_inventory_error = reason
|
||||
_last_inventory_snapshot = {}
|
||||
_render_inventory_label()
|
||||
|
||||
|
||||
func _render_inventory_label() -> void:
|
||||
if not is_instance_valid(_inventory_label):
|
||||
return
|
||||
var header := "Inventory: (refresh: I)"
|
||||
if not _inventory_error.is_empty():
|
||||
_inventory_label.text = "%s\nerror — %s" % [header, _inventory_error]
|
||||
return
|
||||
if _last_inventory_snapshot.is_empty():
|
||||
_inventory_label.text = "%s\nLoading…" % header
|
||||
return
|
||||
var lines: PackedStringArray = [header, "Bag:"]
|
||||
var bag_lines: PackedStringArray = _format_inventory_slot_lines(
|
||||
_last_inventory_snapshot.get("bagSlots", []), false
|
||||
)
|
||||
if bag_lines.is_empty():
|
||||
lines.append(" (empty)")
|
||||
else:
|
||||
for line in bag_lines:
|
||||
lines.append(line)
|
||||
lines.append("Equipment:")
|
||||
var equip_lines: PackedStringArray = _format_inventory_slot_lines(
|
||||
_last_inventory_snapshot.get("equipmentSlots", []), true
|
||||
)
|
||||
if equip_lines.is_empty():
|
||||
lines.append(" slot 0: — empty")
|
||||
else:
|
||||
for line in equip_lines:
|
||||
lines.append(line)
|
||||
_inventory_label.text = "\n".join(lines)
|
||||
|
||||
|
||||
func _format_inventory_slot_lines(raw_slots: Variant, always_show_empty_stub: bool) -> PackedStringArray:
|
||||
var out: PackedStringArray = PackedStringArray()
|
||||
if raw_slots == null or not raw_slots is Array:
|
||||
if always_show_empty_stub:
|
||||
out.append(" slot 0: — empty")
|
||||
return out
|
||||
for slot_variant in raw_slots as Array:
|
||||
if not slot_variant is Dictionary:
|
||||
continue
|
||||
var slot: Dictionary = slot_variant
|
||||
var slot_index: int = int(slot.get("slotIndex", -1))
|
||||
var quantity: int = int(slot.get("quantity", 0))
|
||||
if quantity <= 0:
|
||||
if always_show_empty_stub and slot_index == 0:
|
||||
out.append(" slot 0: — empty")
|
||||
continue
|
||||
var item_id: String = str(slot.get("itemId", ""))
|
||||
var label: String = item_id
|
||||
if is_instance_valid(_item_defs_client) and _item_defs_client.has_method("display_name_for"):
|
||||
label = str(_item_defs_client.call("display_name_for", item_id))
|
||||
out.append(" slot %d: %s x%d" % [slot_index, label, quantity])
|
||||
return out
|
||||
|
||||
|
||||
func _request_inventory_refresh() -> void:
|
||||
if is_instance_valid(_inventory_client) and _inventory_client.has_method("request_sync_from_server"):
|
||||
_inventory_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void:
|
||||
if (
|
||||
is_instance_valid(_cooldown_client)
|
||||
|
|
@ -440,6 +554,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
|||
if event.is_action_pressed("interact_secondary"):
|
||||
_forward_interact_post("post_interact_resource")
|
||||
return
|
||||
if event.is_action_pressed("inventory_refresh"):
|
||||
_request_inventory_refresh()
|
||||
return
|
||||
if event is InputEventKey:
|
||||
var k := event as InputEventKey
|
||||
if k.pressed and not k.echo and (k.physical_keycode == KEY_E or k.keycode == KEY_E):
|
||||
|
|
@ -448,6 +565,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
|||
if k.pressed and not k.echo and (k.physical_keycode == KEY_R or k.keycode == KEY_R):
|
||||
_forward_interact_post("post_interact_resource")
|
||||
return
|
||||
if k.pressed and not k.echo and (k.physical_keycode == KEY_I or k.keycode == KEY_I):
|
||||
_request_inventory_refresh()
|
||||
return
|
||||
if not event.is_action_pressed("dev_toggle_occluder_obstacle"):
|
||||
return
|
||||
call_deferred("_dev_toggle_obstacle_smoke_deferred")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-72: `InventoryClient` GET parse + failure signals.
|
||||
|
||||
const InventoryClient := preload("res://scripts/inventory_client.gd")
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _empty_inventory_json() -> String:
|
||||
var bag_parts: PackedStringArray = PackedStringArray()
|
||||
for i in range(24):
|
||||
bag_parts.append('{"slotIndex":%d,"quantity":0}' % i)
|
||||
var bag: String = ", ".join(bag_parts)
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","bagSlots":[%s],'
|
||||
+ '"equipmentSlots":[{"slotIndex":0,"quantity":0}]}'
|
||||
) % bag
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = InventoryClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_inventory_json_empty_grid() -> void:
|
||||
# Arrange
|
||||
var json := _empty_inventory_json()
|
||||
# Act
|
||||
var snapshot: Variant = InventoryClient.parse_inventory_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var bag: Variant = (snapshot as Dictionary).get("bagSlots", null)
|
||||
assert_that(bag is Array).is_true()
|
||||
assert_that((bag as Array).size()).is_equal(24)
|
||||
|
||||
|
||||
func test_request_sync_emits_inventory_received() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _empty_inventory_json()
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("inventory_received")
|
||||
assert_that(transport.last_url).contains("/inventory")
|
||||
|
||||
|
||||
func test_http_404_emits_inventory_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("inventory_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
||||
|
||||
func test_malformed_json_emits_inventory_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = "[]"
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("inventory_sync_failed")
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-72: `ItemDefinitionsClient` parses world item defs and resolves display names.
|
||||
|
||||
const ItemDefsClient := preload("res://scripts/item_definitions_client.gd")
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
var _queue: Array[Dictionary] = []
|
||||
|
||||
func enqueue(result: int, code: int, body: String) -> void:
|
||||
_queue.append({"result": result, "code": code, "body": body})
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
if _queue.is_empty():
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
var r: Dictionary = _queue.pop_front()
|
||||
request_completed.emit(
|
||||
r["result"], r["code"], PackedStringArray(), str(r["body"]).to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = ItemDefsClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_build_definitions_map_extracts_display_name() -> void:
|
||||
# Arrange
|
||||
var root := {
|
||||
"schemaVersion": 1,
|
||||
"items":
|
||||
[
|
||||
{
|
||||
"id": "scrap_metal_bulk",
|
||||
"displayName": "Scrap Metal (Bulk)",
|
||||
"inventorySlotKind": "bag",
|
||||
}
|
||||
],
|
||||
}
|
||||
# Act
|
||||
var built: Dictionary = ItemDefsClient.build_definitions_map(root)
|
||||
# Assert
|
||||
assert_that(built.has("scrap_metal_bulk")).is_true()
|
||||
assert_that(built["scrap_metal_bulk"]["displayName"]).is_equal("Scrap Metal (Bulk)")
|
||||
|
||||
|
||||
func test_parse_mock_transport_json_body() -> void:
|
||||
# Arrange
|
||||
var json := (
|
||||
'{"schemaVersion":1,"items":[{"id":"scrap_metal_bulk",'
|
||||
+ '"displayName":"Scrap Metal (Bulk)","inventorySlotKind":"bag"}]}'
|
||||
)
|
||||
# Act
|
||||
var parsed: Variant = JSON.parse_string(json)
|
||||
var built: Dictionary = ItemDefsClient.build_definitions_map(parsed)
|
||||
# Assert
|
||||
assert_that(built.has("scrap_metal_bulk")).is_true()
|
||||
|
||||
|
||||
func test_handler_body_bytes_parse_builds_map() -> void:
|
||||
# Arrange
|
||||
var body_json := (
|
||||
'{"schemaVersion":1,"items":[{"id":"scrap_metal_bulk",'
|
||||
+ '"displayName":"Scrap Metal (Bulk)","inventorySlotKind":"bag"}]}'
|
||||
)
|
||||
var body_bytes: PackedByteArray = body_json.to_utf8_buffer()
|
||||
# Act
|
||||
var text: String = body_bytes.get_string_from_utf8()
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
var built: Dictionary = ItemDefsClient.build_definitions_map(parsed)
|
||||
# Assert
|
||||
assert_that(int((parsed as Dictionary).get("schemaVersion", -1))).is_equal(1)
|
||||
assert_that(built.has("scrap_metal_bulk")).is_true()
|
||||
|
||||
|
||||
func test_request_sync_hits_definitions_endpoint_and_emits() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
var body_json := (
|
||||
'{"schemaVersion":1,"items":[{"id":"scrap_metal_bulk",'
|
||||
+ '"displayName":"Scrap Metal (Bulk)","inventorySlotKind":"bag"}]}'
|
||||
)
|
||||
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, body_json)
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(transport.last_url).contains("/game/world/item-definitions")
|
||||
assert_signal(c).is_emitted("definitions_ready")
|
||||
|
||||
|
||||
func test_http_error_does_not_emit_definitions_ready() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 500, "{}")
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).wait_until(400).is_not_emitted("definitions_ready")
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue