Merge pull request #108 from ViPro-Technologies/NEO-74-e3m2-client-craft-ui-recipe-list

NEO-74: Client craft UI + recipe list (E3S5-03)
pull/109/head
VinPropane 2026-05-24 20:29:48 -04:00 committed by GitHub
commit 6867ab4e55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1078 additions and 20 deletions

View File

@ -154,6 +154,16 @@ Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md).
## Craft UI + recipe list (NEO-74)
- **`GET /game/world/recipe-definitions`** — eight prototype recipes on boot; scrollable panel below **`SkillProgressionLabel`**.
- **`POST /game/players/{id}/craft`** — JSON body `schemaVersion`, `recipeId`, `quantity` (default **1**); see [server README — Craft HTTP](../server/README.md).
- **Scripts:** `scripts/recipe_definitions_client.gd`, `scripts/craft_client.gd`, `scripts/craft_recipe_panel.gd`; wired from `main.gd`.
- **HUD:** `UICanvas/CraftFeedbackLabel` — success **`Craft: +N {displayName}`** from POST **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**. **`SkillProgressionLabel`** adds **`refine`** row (boot + post-craft refresh). **`InventoryLabel`** refreshes on craft success only (no **I**).
- **Panel:** per-recipe **Craft** button (qty **1**); input summary uses cached item **`displayName`** when available.
Full checklist: [`docs/manual-qa/NEO-74.md`](../docs/manual-qa/NEO-74.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)`.
@ -213,7 +223,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`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`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), **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`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

@ -15,6 +15,9 @@
[ext_resource type="Script" path="res://scripts/item_definitions_client.gd" id="14_item_defs"]
[ext_resource type="Script" path="res://scripts/inventory_client.gd" id="15_inventory"]
[ext_resource type="Script" path="res://scripts/skill_progression_client.gd" id="16_skill_prog"]
[ext_resource type="Script" path="res://scripts/recipe_definitions_client.gd" id="17_recipe_defs"]
[ext_resource type="Script" path="res://scripts/craft_client.gd" id="18_craft"]
[ext_resource type="Script" path="res://scripts/craft_recipe_panel.gd" id="19_craft_panel"]
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
geometry_collision_mask = 1
@ -1101,6 +1104,12 @@ script = ExtResource("15_inventory")
[node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008]
script = ExtResource("16_skill_prog")
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
script = ExtResource("17_recipe_defs")
[node name="CraftClient" type="Node" parent="." unique_id=2500010]
script = ExtResource("18_craft")
[node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001]
[node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002]
@ -1174,11 +1183,25 @@ theme_override_font_sizes/font_size = 14
autowrap_mode = 3
text = "Gather: —"
[node name="CraftFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000010]
offset_left = 8.0
offset_top = 404.0
offset_right = 520.0
offset_bottom = 430.0
grow_horizontal = 0
grow_vertical = 0
theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 14
autowrap_mode = 3
text = "Craft: —"
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006]
offset_left = 8.0
offset_top = 408.0
offset_top = 436.0
offset_right = 520.0
offset_bottom = 484.0
offset_bottom = 512.0
grow_horizontal = 0
grow_vertical = 0
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1)
@ -1190,9 +1213,9 @@ text = "Cooldowns: —"
[node name="InventoryLabel" type="Label" parent="UICanvas" unique_id=9000007]
offset_left = 8.0
offset_top = 496.0
offset_top = 524.0
offset_right = 520.0
offset_bottom = 644.0
offset_bottom = 672.0
grow_horizontal = 0
grow_vertical = 0
theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 1)
@ -1205,9 +1228,9 @@ Loading…"
[node name="SkillProgressionLabel" type="Label" parent="UICanvas" unique_id=9000009]
offset_left = 8.0
offset_top = 656.0
offset_top = 684.0
offset_right = 520.0
offset_bottom = 720.0
offset_bottom = 768.0
grow_horizontal = 0
grow_vertical = 0
theme_override_colors/font_color = Color(0.82, 0.9, 1, 1)
@ -1217,3 +1240,20 @@ theme_override_font_sizes/font_size = 14
autowrap_mode = 3
text = "Skills:
Loading…"
[node name="CraftRecipePanel" type="Control" parent="UICanvas" unique_id=9000011]
anchors_preset = 0
offset_left = 8.0
offset_top = 776.0
offset_right = 520.0
offset_bottom = 1056.0
script = ExtResource("19_craft_panel")
[node name="ScrollContainer" type="ScrollContainer" parent="UICanvas/CraftRecipePanel" unique_id=9000012]
layout_mode = 0
offset_right = 512.0
offset_bottom = 280.0
[node name="VBoxContainer" type="VBoxContainer" parent="UICanvas/CraftRecipePanel/ScrollContainer" unique_id=9000013]
layout_mode = 2
size_flags_horizontal = 3

View File

@ -0,0 +1,109 @@
extends Node
## NEO-74: prototype HTTP client for [code]POST /game/players/{id}/craft[/code] (NEO-70).
signal craft_result_received(recipe_id: String, result: Dictionary)
signal craft_request_failed(recipe_id: String, 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
var _current_recipe_id: String = ""
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 is_busy() -> bool:
return _busy
## Returns [code]true[/code] when the HTTP POST was queued.
func request_craft(recipe_id: String, quantity: int = 1) -> bool:
if _busy:
return false
var rid := recipe_id.strip_edges()
if rid.is_empty():
return false
_current_recipe_id = rid
_busy = true
var payload: Dictionary = {
"schemaVersion": SCHEMA_VERSION,
"recipeId": rid,
"quantity": quantity,
}
var url := "%s/game/players/%s/craft" % [_base_root(), _player_path_segment()]
var headers := PackedStringArray(["Content-Type: application/json"])
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
if err != OK:
_busy = false
_current_recipe_id = ""
push_warning("CraftClient: POST failed to start (%s)" % err)
return false
return true
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _player_path_segment() -> String:
return dev_player_id.strip_edges()
static func parse_craft_response_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
if not root.has("success"):
return null
return root
func _on_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
var recipe_echo: String = _current_recipe_id
_current_recipe_id = ""
_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("CraftClient: %s" % reason)
craft_request_failed.emit(recipe_echo, reason)
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("CraftClient: %s" % reason_code)
craft_request_failed.emit(recipe_echo, reason_code)
return
var text := body.get_string_from_utf8()
var parsed: Variant = parse_craft_response_json(text)
if parsed == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("CraftClient: %s" % reason_json)
craft_request_failed.emit(recipe_echo, reason_json)
return
var data: Dictionary = parsed as Dictionary
var success: bool = bool(data.get("success", false))
if not success:
var reason_variant: Variant = data.get("reasonCode", "")
var reason_str: String = reason_variant as String if reason_variant is String else ""
push_warning("craft_denied reasonCode=%s" % reason_str)
craft_result_received.emit(recipe_echo, data.duplicate(true))

View File

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

View File

@ -0,0 +1,83 @@
extends Control
## NEO-74: scrollable prototype recipe list with per-row Craft buttons.
signal craft_requested(recipe_id: String)
var _item_defs_client: Node = null
var _craft_buttons: Array[Button] = []
@onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer
func setup(item_defs_client: Node) -> void:
_item_defs_client = item_defs_client
func populate(recipes: Array) -> void:
if not is_instance_valid(_vbox):
return
for child in _vbox.get_children():
child.queue_free()
_craft_buttons.clear()
for row_variant in recipes:
if not row_variant is Dictionary:
continue
var recipe: Dictionary = row_variant
var recipe_id: String = str(recipe.get("id", ""))
if recipe_id.is_empty():
continue
var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var info := Label.new()
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
info.text = _format_recipe_line(recipe)
row.add_child(info)
var craft_btn := Button.new()
craft_btn.text = "Craft"
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id))
row.add_child(craft_btn)
_vbox.add_child(row)
_craft_buttons.append(craft_btn)
func set_craft_busy(busy: bool) -> void:
for btn in _craft_buttons:
if is_instance_valid(btn):
btn.disabled = busy
func _format_recipe_line(recipe: Dictionary) -> String:
var recipe_id: String = str(recipe.get("id", ""))
var display_name: String = str(recipe.get("displayName", recipe_id))
var inputs_line: String = _format_inputs(recipe.get("inputs", []))
return "%s (%s)\nneeds: %s" % [display_name, recipe_id, inputs_line]
func _format_inputs(raw_inputs: Variant) -> String:
if raw_inputs == null or not raw_inputs is Array:
return ""
var parts: PackedStringArray = PackedStringArray()
for row_variant in raw_inputs as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var item_id: String = str(row.get("itemId", ""))
var qty: int = int(row.get("quantity", 0))
if item_id.is_empty() or qty <= 0:
continue
parts.append("%d× %s" % [qty, _item_display_name(item_id)])
if parts.is_empty():
return ""
return ", ".join(parts)
func _item_display_name(item_id: String) -> String:
if is_instance_valid(_item_defs_client) and _item_defs_client.has_method("display_name_for"):
return str(_item_defs_client.call("display_name_for", item_id))
return item_id
func _on_craft_pressed(recipe_id: String) -> void:
craft_requested.emit(recipe_id)

View File

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

View File

@ -45,6 +45,7 @@ const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
const COOLDOWN_REASON_ON_CD := "on_cooldown"
const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
const SALVAGE_SKILL_ID := "salvage"
const REFINE_SKILL_ID := "refine"
## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0
@ -85,6 +86,8 @@ var _progression_error: String = ""
var _gather_pre_scrap_qty: int = 0
var _gather_pending_interactable_id: String = ""
var _gather_awaiting_inventory_finalize: bool = false
var _cached_recipe_rows: Array = []
var _recipe_defs_error: String = ""
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
@onready var _world: Node3D = $World
@ -101,10 +104,14 @@ var _gather_awaiting_inventory_finalize: bool = false
@onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
@onready var _inventory_label: Label = $UICanvas/InventoryLabel
@onready var _gather_feedback_label: Label = $UICanvas/GatherFeedbackLabel
@onready var _craft_feedback_label: Label = $UICanvas/CraftFeedbackLabel
@onready var _skill_progression_label: Label = $UICanvas/SkillProgressionLabel
@onready var _craft_recipe_panel: Node = $UICanvas/CraftRecipePanel
@onready var _inventory_client: Node = $InventoryClient
@onready var _item_defs_client: Node = $ItemDefinitionsClient
@onready var _skill_progression_client: Node = $SkillProgressionClient
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
@onready var _craft_client: Node = $CraftClient
@onready var _target_client: Node = $TargetSelectionClient
@onready var _interaction_client: Node = $InteractionRequestClient
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
@ -149,6 +156,7 @@ func _ready() -> void:
_setup_inventory_sync()
_setup_skill_progression_sync()
_setup_gather_interact_feedback()
_setup_craft_ui()
func _physics_process(_delta: float) -> void:
@ -358,6 +366,7 @@ func _apply_authority_http_config_to_client(client: Node) -> void:
func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
_populate_craft_recipe_panel_if_ready()
if not _inventory_error.is_empty():
return
if not _last_inventory_snapshot.is_empty():
@ -504,12 +513,18 @@ func _render_skill_progression_label() -> void:
_skill_progression_label.text = "%s\nLoading…" % header
return
var salvage_row: Dictionary = _skill_row(SALVAGE_SKILL_ID)
if salvage_row.is_empty():
_skill_progression_label.text = "%s\nsalvage: — (missing row)" % header
var refine_row: Dictionary = _skill_row(REFINE_SKILL_ID)
if salvage_row.is_empty() or refine_row.is_empty():
_skill_progression_label.text = "%s\nsalvage/refine: — (missing row)" % header
return
var level: int = int(salvage_row.get("level", 0))
var xp: int = int(salvage_row.get("xp", 0))
_skill_progression_label.text = "%s\nsalvage: level %d, xp %d" % [header, level, xp]
var salvage_level: int = int(salvage_row.get("level", 0))
var salvage_xp: int = int(salvage_row.get("xp", 0))
var refine_level: int = int(refine_row.get("level", 0))
var refine_xp: int = int(refine_row.get("xp", 0))
_skill_progression_label.text = (
"%s\nsalvage: level %d, xp %d\nrefine: level %d, xp %d"
% [header, salvage_level, salvage_xp, refine_level, refine_xp]
)
func _skill_row(skill_id: String) -> Dictionary:
@ -528,6 +543,111 @@ func _render_gather_feedback_label(text: String = "Gather: —") -> void:
_gather_feedback_label.text = text
func _setup_craft_ui() -> void:
# NEO-74: recipe defs + craft POST + scrollable recipe panel.
_apply_authority_http_config_to_client(_recipe_defs_client)
_apply_authority_http_config_to_client(_craft_client)
if _craft_recipe_panel.has_method("setup"):
_craft_recipe_panel.call("setup", _item_defs_client)
if _recipe_defs_client.has_signal("recipes_ready"):
_recipe_defs_client.connect("recipes_ready", Callable(self, "_on_recipes_ready"))
if _recipe_defs_client.has_signal("recipes_sync_failed"):
_recipe_defs_client.connect(
"recipes_sync_failed", Callable(self, "_on_recipes_sync_failed")
)
if _craft_recipe_panel.has_signal("craft_requested"):
_craft_recipe_panel.connect("craft_requested", Callable(self, "_on_craft_requested"))
if _craft_client.has_signal("craft_result_received"):
_craft_client.connect("craft_result_received", Callable(self, "_on_craft_result_received"))
if _craft_client.has_signal("craft_request_failed"):
_craft_client.connect("craft_request_failed", Callable(self, "_on_craft_request_failed"))
_render_craft_feedback_label()
if _recipe_defs_client.has_method("request_sync_from_server"):
_recipe_defs_client.call("request_sync_from_server")
func _on_recipes_ready(recipes: Array) -> void:
if not _recipe_defs_error.is_empty():
_render_craft_feedback_label()
_recipe_defs_error = ""
_cached_recipe_rows = recipes.duplicate(true)
_populate_craft_recipe_panel_if_ready()
func _on_recipes_sync_failed(reason: String) -> void:
_recipe_defs_error = reason
_cached_recipe_rows = []
_render_craft_feedback_label("Recipes: error — %s" % reason)
func _populate_craft_recipe_panel_if_ready() -> void:
if _cached_recipe_rows.is_empty():
return
if is_instance_valid(_craft_recipe_panel) and _craft_recipe_panel.has_method("populate"):
_craft_recipe_panel.call("populate", _cached_recipe_rows)
func _on_craft_requested(recipe_id: String) -> void:
if not is_instance_valid(_craft_client):
return
if _craft_client.has_method("is_busy") and bool(_craft_client.call("is_busy")):
return
if _craft_recipe_panel.has_method("set_craft_busy"):
_craft_recipe_panel.call("set_craft_busy", true)
if _craft_client.has_method("request_craft"):
var started: bool = bool(_craft_client.call("request_craft", recipe_id, 1))
if not started and _craft_recipe_panel.has_method("set_craft_busy"):
_craft_recipe_panel.call("set_craft_busy", false)
func _on_craft_result_received(_recipe_id: String, result: Dictionary) -> void:
if _craft_recipe_panel.has_method("set_craft_busy"):
_craft_recipe_panel.call("set_craft_busy", false)
if bool(result.get("success", false)):
_render_craft_feedback_label(_format_craft_success_line(result.get("outputsGranted", [])))
_request_inventory_refresh()
if (
is_instance_valid(_skill_progression_client)
and _skill_progression_client.has_method("request_sync_from_server")
):
_skill_progression_client.call("request_sync_from_server")
return
var rc := str(result.get("reasonCode", "")).strip_edges()
if rc.is_empty():
_render_craft_feedback_label("Craft: denied (no reasonCode)")
else:
_render_craft_feedback_label("Craft: denied — %s" % rc)
func _on_craft_request_failed(_recipe_id: String, reason: String) -> void:
if _craft_recipe_panel.has_method("set_craft_busy"):
_craft_recipe_panel.call("set_craft_busy", false)
_render_craft_feedback_label("Craft: failed — %s" % reason)
func _format_craft_success_line(raw_outputs: Variant) -> String:
if raw_outputs == null or not raw_outputs is Array:
return "Craft: ok (no outputsGranted)"
var parts: PackedStringArray = PackedStringArray()
for row_variant in raw_outputs as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var item_id: String = str(row.get("itemId", ""))
var qty: int = int(row.get("quantity", 0))
if item_id.is_empty() or qty <= 0:
continue
parts.append("+%d %s" % [qty, _inventory_item_label(item_id)])
if parts.is_empty():
return "Craft: ok (empty outputsGranted)"
return "Craft: %s" % ", ".join(parts)
func _render_craft_feedback_label(text: String = "Craft: —") -> void:
if is_instance_valid(_craft_feedback_label):
_craft_feedback_label.text = text
func _clear_gather_session() -> void:
_gather_pending_interactable_id = ""
_gather_awaiting_inventory_finalize = false

View File

@ -0,0 +1,83 @@
extends Node
## NEO-74: fetches [code]GET /game/world/recipe-definitions[/code] (NEO-68).
## Caches ordered recipe rows for craft UI.
signal recipes_ready(recipes: Array)
signal recipes_sync_failed(reason: String)
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 _recipes: Array = []
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/recipe-definitions" % _base_root()
var err: Error = _http.request(url)
if err != OK:
var reason := "GET failed to start (%s)" % err
push_warning("RecipeDefinitionsClient: %s" % reason)
_busy = false
recipes_sync_failed.emit(reason)
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
static func parse_recipes_json(text: String) -> Variant:
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
return null
var root: Dictionary = parsed
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
return null
var recipes: Variant = root.get("recipes", null)
if recipes == null or not recipes is Array:
return null
return recipes as Array
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("RecipeDefinitionsClient: %s" % reason)
recipes_sync_failed.emit(reason)
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("RecipeDefinitionsClient: %s" % reason_code)
recipes_sync_failed.emit(reason_code)
return
var text := body.get_string_from_utf8()
var recipes: Variant = parse_recipes_json(text)
if recipes == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("RecipeDefinitionsClient: %s" % reason_json)
recipes_sync_failed.emit(reason_json)
return
_recipes = (recipes as Array).duplicate(true)
recipes_ready.emit(_recipes.duplicate(true))

View File

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

View File

@ -0,0 +1,144 @@
extends GdUnitTestSuite
## NEO-74: `CraftClient` POST payload + success/deny parse (craft panel wiring tested separately).
const CraftClient := preload("res://scripts/craft_client.gd")
var _failed_reason: String = ""
var _failed_recipe_id: String = ""
func _capture_failed(recipe_id: String, reason: String) -> void:
_failed_recipe_id = recipe_id
_failed_reason = reason
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var last_body: String = ""
var response_code: int = 200
var body_json: String = ""
func request(
url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
request_data: String = ""
) -> Error:
last_url = url
last_body = request_data
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
static func _success_craft_json() -> String:
return (
'{"schemaVersion":1,"success":true,"reasonCode":null,'
+ '"inputsConsumed":[{"itemId":"scrap_metal_bulk","quantity":5}],'
+ '"outputsGranted":[{"itemId":"refined_plate_stock","quantity":1}],'
+ '"xpGrantSummary":{"skillId":"refine","amount":10,"sourceKind":"craft_refine"}}'
)
func _make_client(transport: Node) -> Node:
var c: Node = CraftClient.new()
c.set("injected_http", transport)
auto_free(transport)
auto_free(c)
add_child(c)
return c
func test_request_craft_posts_recipe_id_and_schema_version() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _success_craft_json()
var c := _make_client(transport)
# Act
var started: bool = bool(c.call("request_craft", "refine_scrap_standard", 1))
# Assert
assert_that(started).is_true()
assert_that(transport.last_url).contains("/craft")
var parsed: Variant = JSON.parse_string(transport.last_body)
assert_that(parsed is Dictionary).is_true()
var body: Dictionary = parsed as Dictionary
assert_that(int(body.get("schemaVersion", -1))).is_equal(1)
assert_that(str(body.get("recipeId", ""))).is_equal("refine_scrap_standard")
assert_that(int(body.get("quantity", -1))).is_equal(1)
func test_success_response_emits_craft_result_received() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _success_craft_json()
var c := _make_client(transport)
var got: Array = []
c.connect(
"craft_result_received",
func(recipe_id: String, result: Dictionary) -> void: got.append([recipe_id, result])
)
# Act
c.call("request_craft", "refine_scrap_standard")
# Assert
assert_that(got.size()).is_equal(1)
assert_that(str(got[0][0])).is_equal("refine_scrap_standard")
var result: Dictionary = got[0][1]
assert_that(bool(result.get("success", false))).is_true()
var outputs: Variant = result.get("outputsGranted", null)
assert_that(outputs is Array).is_true()
assert_that((outputs as Array).size()).is_equal(1)
func test_deny_response_emits_craft_result_with_reason_code() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = (
'{"schemaVersion":1,"success":false,"reasonCode":"insufficient_materials",'
+ '"inputsConsumed":[],"outputsGranted":[]}'
)
var c := _make_client(transport)
var got: Array = []
c.connect(
"craft_result_received",
func(_recipe_id: String, result: Dictionary) -> void: got.append(result)
)
# Act
c.call("request_craft", "refine_scrap_standard")
# Assert
var result: Dictionary = got[0]
assert_that(bool(result.get("success", true))).is_false()
assert_that(str(result.get("reasonCode", ""))).is_equal("insufficient_materials")
func test_http_error_emits_craft_request_failed() -> void:
# Arrange
_failed_reason = ""
var transport := MockHttpTransport.new()
transport.response_code = 404
var c := _make_client(transport)
c.connect("craft_request_failed", Callable(self, "_capture_failed"))
# Act
c.call("request_craft", "refine_scrap_standard")
# Assert
assert_that(_failed_reason).contains("404")
func test_request_craft_clears_busy_after_sync_mock_response() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _success_craft_json()
var c := _make_client(transport)
# Act
var started: bool = bool(c.call("request_craft", "refine_scrap_standard", 1))
# Assert
assert_that(started).is_true()
assert_that(bool(c.call("is_busy"))).is_false()

View File

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

View File

@ -0,0 +1,96 @@
extends GdUnitTestSuite
## NEO-74: successful craft triggers inventory + skill refresh; deny skips refresh.
const CraftClient := preload("res://scripts/craft_client.gd")
class MockCraftTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var body_json: String = '{"schemaVersion":1,"success":true,"outputsGranted":[]}'
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
_request_data: String = ""
) -> Error:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer()
)
return OK
class SpySyncClient:
extends Node
var sync_calls: int = 0
func request_sync_from_server() -> void:
sync_calls += 1
class CraftRefreshHarness:
extends Node
var inventory_sync_calls: int = 0
var skill_sync_calls: int = 0
var _inventory: SpySyncClient
var _skill: SpySyncClient
var _craft: Node
func setup(parent: Node, transport: Node) -> void:
_inventory = SpySyncClient.new()
_skill = SpySyncClient.new()
_craft = CraftClient.new()
_craft.set("injected_http", transport)
parent.add_child(_craft)
parent.add_child(_inventory)
parent.add_child(_skill)
_craft.connect("craft_result_received", Callable(self, "_on_craft_result"))
func _on_craft_result(_recipe_id: String, result: Dictionary) -> void:
if not bool(result.get("success", false)):
return
_inventory.request_sync_from_server()
_skill.request_sync_from_server()
func post_craft(recipe_id: String) -> void:
_craft.call("request_craft", recipe_id)
func test_successful_craft_triggers_both_refreshes() -> void:
# Arrange
var transport := MockCraftTransport.new()
var harness := CraftRefreshHarness.new()
auto_free(transport)
auto_free(harness)
add_child(harness)
harness.setup(self, transport)
monitor_signals(harness._craft)
# Act
harness.post_craft("refine_scrap_standard")
# Assert
assert_signal(harness._craft).is_emitted("craft_result_received")
assert_that(harness._inventory.sync_calls).is_equal(1)
assert_that(harness._skill.sync_calls).is_equal(1)
func test_denied_craft_skips_refreshes() -> void:
# Arrange
var transport := MockCraftTransport.new()
transport.body_json = (
'{"schemaVersion":1,"success":false,"reasonCode":"insufficient_materials",'
+ '"inputsConsumed":[],"outputsGranted":[]}'
)
var harness := CraftRefreshHarness.new()
auto_free(transport)
auto_free(harness)
add_child(harness)
harness.setup(self, transport)
# Act
harness.post_craft("refine_scrap_standard")
# Assert
assert_that(harness._inventory.sync_calls).is_equal(0)
assert_that(harness._skill.sync_calls).is_equal(0)

View File

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

View File

@ -0,0 +1,112 @@
extends GdUnitTestSuite
## NEO-74: `RecipeDefinitionsClient` GET parse + `recipes_ready` signal.
## `main.gd` repopulates craft panel on item `definitions_ready` even when inventory GET failed.
const RecipeDefinitionsClient := preload("res://scripts/recipe_definitions_client.gd")
var _recipes_capture: Array = []
func _capture_recipes(recipes: Array) -> void:
_recipes_capture = recipes
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 _two_recipes_json() -> String:
return (
'{"schemaVersion":1,"recipes":['
+ '{"id":"make_armor_quick","displayName":"Quick Armor","recipeKind":"make",'
+ '"requiredSkillId":"refine","inputs":[],"outputs":[]},'
+ '{"id":"refine_scrap_standard","displayName":"Refine Scrap (Standard)",'
+ '"recipeKind":"process","requiredSkillId":"refine",'
+ '"inputs":[{"itemId":"scrap_metal_bulk","quantity":5}],'
+ '"outputs":[{"itemId":"refined_plate_stock","quantity":1}]}'
+ "]}"
)
func _make_client(transport: Node) -> Node:
var c: Node = RecipeDefinitionsClient.new()
c.set("injected_http", transport)
auto_free(transport)
auto_free(c)
add_child(c)
return c
func test_parse_recipes_json_reads_array() -> void:
# Arrange
var json := _two_recipes_json()
# Act
var recipes: Variant = RecipeDefinitionsClient.parse_recipes_json(json)
# Assert
assert_that(recipes is Array).is_true()
assert_that((recipes as Array).size()).is_equal(2)
func test_request_sync_emits_recipes_ready_in_order() -> void:
# Arrange
_recipes_capture = []
var transport := MockHttpTransport.new()
transport.body_json = _two_recipes_json()
var c := _make_client(transport)
c.connect("recipes_ready", Callable(self, "_capture_recipes"))
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).is_emitted("recipes_ready")
assert_that(_recipes_capture.size()).is_equal(2)
assert_that(str(_recipes_capture[0].get("id", ""))).is_equal("make_armor_quick")
assert_that(transport.last_url).contains("/recipe-definitions")
func test_schema_mismatch_emits_recipes_sync_failed() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = '{"schemaVersion":99,"recipes":[]}'
var c := _make_client(transport)
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
assert_signal(c).is_emitted("recipes_sync_failed")
func test_http_error_emits_recipes_sync_failed_with_status() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.response_code = 503
var c := _make_client(transport)
var got: Array = []
c.connect("recipes_sync_failed", func(reason: String) -> void: got.append(reason))
# Act
c.call("request_sync_from_server")
# Assert
assert_that(got.size()).is_equal(1)
assert_that(str(got[0])).contains("503")

View File

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

View File

@ -7,7 +7,7 @@
| **Module ID** | E3.M2 |
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
| **Stage target** | Prototype |
| **Status** | Ready — Slice 3 server backlog [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md): E3M2-01 [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) through E3M2-07 [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) landed (see [dependency register](module_dependency_register.md)); client craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) is Slice 5 |
| **Status** | Ready — Slice 3 server backlog [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md): E3M2-01 [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) through E3M2-07 [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) landed; Slice 5 client craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) landed (capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) remaining) |
## Implementation snapshot
@ -88,7 +88,7 @@ The **first shipped eight-recipe spine** under `content/recipes/*.json` is **fro
Epic 3 **Slice 3** — gather → refine → item used in combat or quest; telemetry `item_crafted`, `craft_failed`, time-to-first-craft.
**Linear backlog (decomposed):** [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-01** [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) (content + CI) through **E3M2-07** [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) (telemetry hooks). **Client (Slice 5):** craft UI — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-03** [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74).
**Linear backlog (decomposed):** [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-01** [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) (content + CI) through **E3M2-07** [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) (telemetry hooks). **Client (Slice 5):** craft UI — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-03** [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) (**landed** — `recipe_definitions_client.gd`, `craft_client.gd`, `craft_recipe_panel.gd`, [`NEO-74` manual QA](../../manual-qa/NEO-74.md)).
## Risks and telemetry

View File

@ -53,9 +53,9 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) |
| 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. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **Slice 5 client (remaining):** 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-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-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/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` |
| 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):** 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) |
| 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. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **Slice 5 client (remaining):** capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-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/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` |
| 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):** 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. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **Slice 5 client (remaining):** 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-74](../../plans/NEO-74-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

@ -0,0 +1,30 @@
# NEO-74 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-74 |
| Title | E3.M2: Client craft UI + recipe list (E3S5-03) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-74/e3m2-client-craft-ui-recipe-list-e3s5-03 |
| Plan | `docs/plans/NEO-74-implementation-plan.md` |
| Branch | `NEO-74-e3m2-client-craft-ui-recipe-list` |
## Preconditions
- Server running with default dev player (`dev-local-1`); craft HTTP landed (NEO-70); recipe defs GET landed (NEO-68); inventory HUD (NEO-72) and gather/skill HUD (NEO-73) working.
## Checklist
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows after boot.
3. Scroll the recipe panel below skills — **eight** prototype recipes with **Craft** buttons.
4. On a fresh player (empty bag), click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: denied — insufficient_materials`**. Press **I** — inventory unchanged.
5. Gather **≥5** **`scrap_metal_bulk`** (see [NEO-73 manual QA](NEO-73.md) **R** at resource nodes).
6. Click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: +1 …`** (refined plate output). **`InventoryLabel`** updates without **I**. **`refine`** xp increases on **`SkillProgressionLabel`**.
7. Gather **≥1** more scrap if needed; ensure **≥2** **`refined_plate_stock`** and **≥1** **`scrap_metal_bulk`** in bag (craft **`refine_scrap_standard`** again or gather).
8. Click **Craft** on **`make_field_stim_mk0`**. Success feedback shows **`field_stim_mk0`** output; bag lists consumable; **`refine`** xp increases again.
9. Optional deny spot-check: with a full bag, attempt a craft whose output cannot fit — expect **`Craft: denied — inventory_full`**.
## Notes
- Craft quantity is fixed at **1** per button (no spinner in this story).
- Success copy comes from POST **`outputsGranted`**; inventory and skill rows refresh via GET after success only.

View File

@ -127,9 +127,9 @@ Working backlog for **Epic 3 — Slice 5** ([client economy integration](../deco
**Acceptance criteria**
- [ ] Player can **`refine_scrap_standard`** and **`make_field_stim_mk0`** from UI when materials allow.
- [ ] Denied craft shows **`reasonCode`** on HUD; inventory unchanged (verify via GET).
- [ ] **Refine** skill XP visible and increases on successful craft.
- [x] Player can **`refine_scrap_standard`** and **`make_field_stim_mk0`** from UI when materials allow.
- [x] Denied craft shows **`reasonCode`** on HUD; inventory unchanged (verify via GET).
- [x] **Refine** skill XP visible and increases on successful craft.
---

View File

@ -0,0 +1,168 @@
# NEO-74 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-74 |
| **Title** | E3.M2: Client craft UI + recipe list (E3S5-03) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-74/e3m2-client-craft-ui-recipe-list-e3s5-03 |
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 **Slice 5** (client 3 of 4) · backlog **E3S5-03** |
| **Branch** | `NEO-74-e3m2-client-craft-ui-recipe-list` |
| **Server deps** | [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) — `GET /game/world/recipe-definitions` (**Done**); [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) — `POST …/craft` (**Done**); [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — inventory HUD + refresh (**Done**); [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) — skill-progression GET (**Done**) |
| **Client deps** | [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — `SkillProgressionClient`, gather refresh pattern (**Done** on `main`) |
| **Pattern** | [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) / [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — thin HTTP clients, `main.gd` wiring, debug HUD labels, GdUnit mock transport |
| **Downstream** | [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) capstone gather→refine→make loop; optional HUD collapse |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Craft success copy** | What should success feedback show? | **`outputsGranted` from POST body** — e.g. `Craft: +1 {displayName}`; still GET inventory + skill-progression after success (E3S5 refresh policy). | **User:** POST **`outputsGranted`** summary. |
| **Craft feedback label** | Dedicated label vs reuse gather? | **Dedicated `CraftFeedbackLabel`** below **`GatherFeedbackLabel`** — mirrors cast/gather separation. | **User:** dedicated **`CraftFeedbackLabel`**. |
| **Recipe panel layout** | Where to place recipe list + Craft buttons? | **Left-column `ScrollContainer`** below **`SkillProgressionLabel`** — matches debug HUD stack; NEO-75 can collapse if crowded. | **User:** left scroll below skills. |
No other blocking decisions — list **all eight** prototype recipes per [E3S5 kickoff table](E3S5-client-prototype-backlog.md#kickoff-decisions-slice-5-client); qty **1** only (no spinner); deny copy **`Craft: denied — {reasonCode}`** mirrors gather; **`refine`** row added to existing **`SkillProgressionLabel`** (salvage row unchanged).
## Goal, scope, and out-of-scope
**Goal:** Player selects a prototype recipe and **`POST /game/players/{id}/craft`** from Godot; sees success/deny, updated inventory, and **refine** XP on HUD — not Output-panel-only proof.
**In scope (from Linear + [E3S5-03](E3S5-client-prototype-backlog.md#e3s5-03--client-craft-ui--recipe-list-e3m2)):**
- **`recipe_definitions_client.gd`:** GET world recipe defs (NEO-68); cache ordered list; emit **`recipes_ready`**.
- **`craft_client.gd`:** POST `{ schemaVersion: 1, recipeId }` (qty **1** implicit); parse success/deny + **`reasonCode`** + I/O lists; emit **`craft_result_received`**.
- **`craft_recipe_panel.gd`** (child under **`UICanvas`**): build scrollable recipe rows (id / **`displayName`**, inputs summary via **`ItemDefinitionsClient.display_name_for`**, **Craft** **`Button`** per row); signal **`craft_requested(recipe_id)`**.
- **`CraftFeedbackLabel`:** success **`Craft: +N {displayName}`** from **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**; no refresh on deny.
- Extend **`SkillProgressionLabel`:** add **`refine: level N, xp M`** row below salvage; boot hydrate + refresh after successful craft.
- **`main.gd` craft path:** on **`craft_requested`** → **`craft_client.request_craft(recipe_id)`**; on success → paint feedback from POST outputs → **`inventory_client.request_sync_from_server()`** + **`skill_progression_client.request_sync_from_server()`**; on deny → feedback only (inventory unchanged per AC).
- Boot: chain **`recipe_definitions_client.request_sync_from_server()`** after item defs (or parallel); panel populates on **`recipes_ready`**.
- GdUnit: craft POST payload/parse; recipe defs parse; deny/success signal paths.
- **`client/README.md`** craft UI subsection.
- **`docs/manual-qa/NEO-74.md`**.
**Out of scope (from Linear):**
- Station/bench world objects; craft quantity spinner; recipe filtering by skill level.
- HUD collapse/toggle (NEO-75 optional).
## Acceptance criteria checklist
- [x] **`refine_scrap_standard`** and **`make_field_stim_mk0`** craftable from UI when materials allow.
- [x] Denied craft shows **`reasonCode`** on **`CraftFeedbackLabel`**; inventory unchanged (verify via GET / no auto-refresh on deny).
- [x] **Refine** skill level/XP visible on **`SkillProgressionLabel`** and increases after successful craft.
## Implementation reconciliation (shipped)
- **Clients:** `recipe_definitions_client.gd`, `craft_client.gd`; `craft_recipe_panel.gd` scroll list + **Craft** buttons.
- **HUD:** `CraftFeedbackLabel` (POST **`outputsGranted`** / deny); **`SkillProgressionLabel`** salvage + **refine** rows; inventory refresh on craft success only.
- **Tests:** `craft_client_test.gd`, `recipe_definitions_client_test.gd`, `craft_feedback_refresh_test.gd`.
- **Docs:** `client/README.md` craft section; [`NEO-74` manual QA](../manual-qa/NEO-74.md).
## Technical approach
1. **`recipe_definitions_client.gd`**
- Mirror **`item_definitions_client.gd`**: injectable HTTP, **`_busy`** guard, **`GET …/world/recipe-definitions`**.
- **`parse_recipes_json(text)`** static: require **`schemaVersion` 1**, **`recipes`** array.
- Cache **`Array`** of recipe dicts in server id order; emit **`recipes_ready(recipes)`**.
- Helper **`recipe_by_id(id)`** for panel lookups.
2. **`craft_client.gd`**
- Mirror **`ability_cast_client.gd`** POST pattern: injectable HTTP, **`_busy`** guard.
- **`request_craft(recipe_id: String, quantity: int = 1) -> bool`**: POST body `{ "schemaVersion": 1, "recipeId": recipe_id, "quantity": quantity }` to **`…/players/{id}/craft`**.
- **`parse_craft_response_json(text)`** static: require **`schemaVersion` 1**, **`success`** bool.
- Signal **`craft_result_received(result: Dictionary)`** with parsed body (includes **`success`**, **`reasonCode`**, **`outputsGranted`**, **`inputsConsumed`**, optional **`xpGrantSummary`**).
- On HTTP transport failure / non-2xx / JSON mismatch: **`craft_request_failed(recipe_id, reason)`** (mirror interaction client failure path).
- **`push_warning`** on deny with stable reason (NEO-31 pattern).
3. **`craft_recipe_panel.gd`**
- **`@export`** refs or **`setup(...)`** for **`ItemDefinitionsClient`** display-name lookup.
- **`populate(recipes: Array)`**: clear **`ScrollContainer/VBoxContainer`** children; for each recipe row:
- **`Label`**: **`{displayName}`** (`{id}`) + inputs line e.g. **`needs: 5× Scrap Metal Bulk`** (join **`inputs`** with **`display_name_for(itemId)`**).
- **`Button`**: text **`Craft`**; pressed → emit **`craft_requested(recipe.get("id"))`**.
- Disable buttons while **`craft_client`** busy (optional **`set_craft_busy(busy)`** from **`main`**).
4. **`main.gd` craft orchestration**
- Constants: **`REFINE_SKILL_ID := "refine"`**.
- State: **`_last_craft_recipe_id: String`**, **`_craft_busy`** (or delegate to client **`_busy`**).
- **`_setup_craft_ui()`**: wire clients, panel, feedback label; connect **`craft_requested`**, **`craft_result_received`**, **`craft_request_failed`**.
- Success handler:
- Format **`outputsGranted`** rows → **`Craft: +{qty} {displayName}`** (multi-output: comma-join).
- Trigger inventory + skill progression refresh (same as gather success).
- Deny handler: **`Craft: denied — {reasonCode}`**; no GET refresh.
- Extend **`_render_skill_progression_label()`**: salvage row + **`refine`** row (two lines under **`Skills:`** header).
5. **Scene layout (`main.tscn`)**
- Add nodes: **`RecipeDefinitionsClient`**, **`CraftClient`**, **`CraftFeedbackLabel`**, **`CraftRecipePanel`** (with **`ScrollContainer` > `VBoxContainer`**).
- **`CraftFeedbackLabel`:** below **`GatherFeedbackLabel`** (~offset_top 404); shift **`CooldownSlotsLabel`** down (~+28px) and cascade inventory/skills/panel offsets to avoid overlap.
- **`CraftRecipePanel`:** below **`SkillProgressionLabel`** (~offset_top 728); fixed height scroll (~200280px) for eight rows.
- HTTP client nodes as siblings (same pattern as **`InventoryClient`**).
6. **Tests**
- **`craft_client_test.gd`:** mock 200 success → **`success=true`**, **`outputsGranted`** parsed; mock 200 deny → **`success=false`**, **`reasonCode=insufficient_materials`**; verify POST URL + JSON body includes **`recipeId`**. AAA layout.
- **`recipe_definitions_client_test.gd`:** mock 200 with two recipes → **`recipes_ready`** length + id order. AAA layout.
- **`craft_feedback_refresh_test.gd`:** stub handler — craft allow signal → inventory + skill **`request_sync_from_server`** invoked; deny → refresh **not** invoked. AAA layout.
7. **Manual QA (`docs/manual-qa/NEO-74.md`)**
- Fresh player: craft deny **`insufficient_materials`** for **`refine_scrap_standard`** → HUD reason + bag unchanged.
- After gather ≥5 scrap: **`refine_scrap_standard`** success → feedback line, inventory shows **`refined_plate_stock`**, refine XP bumped.
- With 2 refined + 1 scrap: **`make_field_stim_mk0`** success → **`field_stim_mk0`** in bag.
- Deny paths spot-check: unknown recipe (if test hook), bag full (optional note — may need pre-fill).
8. **Docs on land**
- Update [E3_M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes.
- Check **E3S5-03** boxes in **`E3S5-client-prototype-backlog.md`**.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-74-implementation-plan.md` | This plan. |
| `client/scripts/recipe_definitions_client.gd` | GET world recipe defs; **`recipes_ready`**; parse v1 **`recipes`** array. |
| `client/scripts/recipe_definitions_client.gd.uid` | Godot uid companion (tracked). |
| `client/scripts/craft_client.gd` | POST craft; **`craft_result_received`** / **`craft_request_failed`**. |
| `client/scripts/craft_client.gd.uid` | Godot uid companion (tracked). |
| `client/scripts/craft_recipe_panel.gd` | Scrollable recipe list + **Craft** buttons; **`craft_requested`** signal. |
| `client/scripts/craft_recipe_panel.gd.uid` | Godot uid companion (tracked). |
| `client/test/craft_client_test.gd` | GdUnit: POST payload, success/deny parse. AAA layout. |
| `client/test/recipe_definitions_client_test.gd` | GdUnit: parse v1 recipes array. AAA layout. |
| `client/test/craft_feedback_refresh_test.gd` | GdUnit: success → inventory + skill refresh; deny → no refresh. AAA layout. |
| `docs/manual-qa/NEO-74.md` | Craft spine UI session: deny, refine, make stim, refine XP delta. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scripts/main.gd` | Boot wiring for recipe defs + craft clients; craft orchestration; **`CraftFeedbackLabel`** render; **`refine`** skill row; authority HTTP config for new clients. |
| `client/scenes/main.tscn` | Add **`RecipeDefinitionsClient`**, **`CraftClient`**, **`CraftFeedbackLabel`**, **`CraftRecipePanel`**; adjust HUD vertical offsets. |
| `client/README.md` | Craft UI subsection: recipe panel, craft POST, feedback labels, boot hydrate, server deps. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `client/test/craft_client_test.gd` | Mock 200 success → **`craft_result_received`** with **`outputsGranted`**; mock deny → **`reasonCode`**; POST body **`recipeId`** + **`schemaVersion` 1**. AAA layout. |
| `client/test/recipe_definitions_client_test.gd` | Mock 200 → **`recipes_ready`** emits ordered array; schema mismatch → no emit. AAA layout. |
| `client/test/craft_feedback_refresh_test.gd` | Craft success handler triggers inventory + skill **`request_sync_from_server`**; deny path skips refresh (spy/mock). AAA layout. |
No new **C#** tests (client-only). No new Bruno (server covered by NEO-68/NEO-70).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **HUD vertical crowding (8 recipes)** | Left scroll with ~200280px max height; full collapse deferred to NEO-75. | **adopted** (kickoff) |
| **Multi-output success copy** | Comma-join **`outputsGranted`** rows — prototype recipes have single output except batch variants. | **adopted** |
| **Craft busy double-click** | Guard with **`craft_client._busy`**; disable panel buttons while in flight. | **adopted** |
| **Deny without inventory refresh** | Do not call inventory GET on **`success=false`** — AC requires unchanged bag; manual QA verifies. | **adopted** |
| **Item display names on recipe inputs** | Reuse landed **`ItemDefinitionsClient`** cache; fall back to raw **`itemId`**. | **adopted** |
| **Slice 5 module docs** | Update alignment register when NEO-74 lands, not at kickoff. | **deferred** (implementation) |
## Decisions (kickoff)
- **Craft success copy:** **`outputsGranted`** from POST response formatted on **`CraftFeedbackLabel`** (user confirmed).
- **Craft feedback:** dedicated **`CraftFeedbackLabel`** below gather (user confirmed).
- **Recipe panel:** left-column **`ScrollContainer`** below **`SkillProgressionLabel`** (user confirmed).
- **Recipe scope:** all **eight** prototype recipes with qty-**1** **Craft** buttons (E3S5 backlog).
- **Skill HUD:** add **`refine`** row to **`SkillProgressionLabel`**; refresh after successful craft.
- **Refresh policy:** GET inventory + skill-progression on craft success only; no GET on deny.

View File

@ -0,0 +1,57 @@
# Code review — NEO-74 client craft UI + recipe list
**Date:** 2026-05-24
**Scope:** Branch `NEO-74-e3m2-client-craft-ui-recipe-list` · commits `dff58ad``c16b610` vs `origin/main`
**Base:** `origin/main`
**Follow-up:** Review feedback addressed (recipe-sync failure HUD, test `.uid` companions, unused `_scroll` binding).
## Verdict
**Approve** — implementation matches the adopted plan and E3S5-03 acceptance criteria; GdUnit suites pass. Follow-up wired **`recipes_sync_failed`** HUD and added test **`.uid`** companions.
## Summary
The branch adds **`recipe_definitions_client.gd`**, **`craft_client.gd`**, and **`craft_recipe_panel.gd`**, wires them from **`main.gd`** with **`CraftFeedbackLabel`**, a scrollable eight-recipe panel, **refine** row on **`SkillProgressionLabel`**, and post-success inventory + skill refresh (deny leaves bag unchanged). Three GdUnit suites cover POST payload/parse, recipe GET parse, and refresh policy. Manual QA, plan reconciliation, E3.M2 module note, alignment register, and E3S5 backlog checkboxes are updated. Design follows NEO-72/73/31 patterns (thin HTTP clients, `_busy` guard, injectable transport, authority config copy, dedicated feedback label). HTTP handlers correctly use **`PackedStringArray`** headers (NEO-72 lesson applied).
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-74-implementation-plan.md`](../plans/NEO-74-implementation-plan.md) | **Matches** — kickoff decisions (POST **`outputsGranted`** copy, dedicated **`CraftFeedbackLabel`**, left scroll panel, qty **1**, deny refresh policy) reflected; acceptance checklist and reconciliation complete. |
| [`docs/plans/E3S5-client-prototype-backlog.md`](../plans/E3S5-client-prototype-backlog.md) · **E3S5-03** | **Matches** — acceptance criteria checked; scope/out-of-scope honored; does not claim capstone (NEO-75) complete. |
| [`docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) | **Matches** — NEO-74 client slice **landed** note added. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M2 row notes NEO-74 landed; NEO-75 cited as remaining Slice 5 client work. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — client sends craft **intent** via POST; inventory/skill display refreshed from server GET after success. |
| [`docs/manual-qa/NEO-74.md`](../manual-qa/NEO-74.md) | **Matches** — deny/success/refine XP walkthrough; optional **`inventory_full`** spot-check. |
| [`client/README.md`](../../client/README.md) craft UI section | **Matches** — endpoints, HUD labels, refresh policy; automated test Scope lists NEO-74 suites. |
| Full-stack epic decomposition | **Matches** — E3S5-03 **client** counterpart for server NEO-68/NEO-70; Godot manual QA present. |
## Blocking issues
(none)
## Suggestions
1. ~~**Wire `recipes_sync_failed` in `main.gd`** — `RecipeDefinitionsClient` emits **`recipes_sync_failed`** (tested in GdUnit) but **`main.gd`** never connects it. On boot failure the recipe panel stays empty with no HUD line (inventory/skill clients show errors). Connect to **`CraftFeedbackLabel`** or **`push_warning`** + a one-line error for parity with **`inventory_sync_failed`** / **`progression_sync_failed`**.~~ **Done.** **`_on_recipes_sync_failed`** paints **`Recipes: error — …`** on **`CraftFeedbackLabel`**.
2. ~~**Add `.gd.uid` companions for new GdUnit test scripts** — `craft_client_test.gd`, `recipe_definitions_client_test.gd`, and `craft_feedback_refresh_test.gd` have no tracked **`*.gd.uid`** files (NEO-73 established this for new tests under `client/test/`). Generate in Godot and commit alongside the tests per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md).~~ **Done.**
## Nits
- ~~Nit: **`craft_recipe_panel.gd`** declares **`@onready var _scroll`** but only uses **`_vbox`** — remove unused binding or use **`_scroll`** for max-height policy.~~ **Done.**
- Nit: **`craft_feedback_refresh_test.gd`** harness duplicates refresh logic from **`main.gd`** (intentional isolation) — acceptable; no **`main.gd`** integration test remains (same gap as NEO-73 gather refresh test).
## Verification
```bash
# Server (separate terminal)
cd server/NeonSprawl.Server && dotnet run
# NEO-74 GdUnit suites (from client/)
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
-a res://test/craft_client_test.gd \
-a res://test/recipe_definitions_client_test.gd \
-a res://test/craft_feedback_refresh_test.gd
```
Manual: follow [`docs/manual-qa/NEO-74.md`](../manual-qa/NEO-74.md) — deny **`insufficient_materials`**, **`refine_scrap_standard`** success, **`make_field_stim_mk0`**, **`refine`** XP delta without pressing **I** on success.