diff --git a/client/README.md b/client/README.md index 5b5028f..9fd2140 100644 --- a/client/README.md +++ b/client/README.md @@ -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. diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 9fc1ea0..7b80b26 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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 diff --git a/client/scripts/craft_client.gd b/client/scripts/craft_client.gd new file mode 100644 index 0000000..e474e18 --- /dev/null +++ b/client/scripts/craft_client.gd @@ -0,0 +1,108 @@ +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 + 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: + _current_recipe_id = "" + push_warning("CraftClient: POST failed to start (%s)" % err) + return false + _busy = true + 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)) diff --git a/client/scripts/craft_client.gd.uid b/client/scripts/craft_client.gd.uid new file mode 100644 index 0000000..46cc001 --- /dev/null +++ b/client/scripts/craft_client.gd.uid @@ -0,0 +1 @@ +uid://bneo74craftclient01 diff --git a/client/scripts/craft_recipe_panel.gd b/client/scripts/craft_recipe_panel.gd new file mode 100644 index 0000000..a9735f3 --- /dev/null +++ b/client/scripts/craft_recipe_panel.gd @@ -0,0 +1,84 @@ +extends Control + +## NEO-74: scrollable prototype recipe list with per-row Craft buttons. + +signal craft_requested(recipe_id: String) + +@onready var _scroll: ScrollContainer = $ScrollContainer +@onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer + +var _item_defs_client: Node = null +var _craft_buttons: Array[Button] = [] + + +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) diff --git a/client/scripts/craft_recipe_panel.gd.uid b/client/scripts/craft_recipe_panel.gd.uid new file mode 100644 index 0000000..acf11b5 --- /dev/null +++ b/client/scripts/craft_recipe_panel.gd.uid @@ -0,0 +1 @@ +uid://bneo74craftpanel01 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 7ae5185..ba813ac 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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,7 @@ 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 = [] @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D @onready var _world: Node3D = $World @@ -101,10 +103,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 +155,7 @@ func _ready() -> void: _setup_inventory_sync() _setup_skill_progression_sync() _setup_gather_interact_feedback() + _setup_craft_ui() func _physics_process(_delta: float) -> void: @@ -362,6 +369,7 @@ func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: return if not _last_inventory_snapshot.is_empty(): _render_inventory_label() + _populate_craft_recipe_panel_if_ready() func _on_inventory_received(snapshot: Dictionary) -> void: @@ -504,12 +512,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 +542,98 @@ 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 _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: + _cached_recipe_rows = recipes.duplicate(true) + _populate_craft_recipe_panel_if_ready() + + +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 diff --git a/client/scripts/recipe_definitions_client.gd b/client/scripts/recipe_definitions_client.gd new file mode 100644 index 0000000..7b20883 --- /dev/null +++ b/client/scripts/recipe_definitions_client.gd @@ -0,0 +1,97 @@ +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 recipes_snapshot() -> Array: + return _recipes.duplicate(true) + + +func recipe_by_id(recipe_id: String) -> Dictionary: + for row_variant in _recipes: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("id", "")) == recipe_id: + return row.duplicate(true) + return {} + + +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)) diff --git a/client/scripts/recipe_definitions_client.gd.uid b/client/scripts/recipe_definitions_client.gd.uid new file mode 100644 index 0000000..448f387 --- /dev/null +++ b/client/scripts/recipe_definitions_client.gd.uid @@ -0,0 +1 @@ +uid://bneo74recipedef01 diff --git a/client/test/craft_client_test.gd b/client/test/craft_client_test.gd new file mode 100644 index 0000000..5213134 --- /dev/null +++ b/client/test/craft_client_test.gd @@ -0,0 +1,132 @@ +extends GdUnitTestSuite + +## NEO-74: `CraftClient` POST payload + success/deny parse. + +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") diff --git a/client/test/craft_feedback_refresh_test.gd b/client/test/craft_feedback_refresh_test.gd new file mode 100644 index 0000000..0f88163 --- /dev/null +++ b/client/test/craft_feedback_refresh_test.gd @@ -0,0 +1,99 @@ +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) diff --git a/client/test/recipe_definitions_client_test.gd b/client/test/recipe_definitions_client_test.gd new file mode 100644 index 0000000..111b409 --- /dev/null +++ b/client/test/recipe_definitions_client_test.gd @@ -0,0 +1,97 @@ +extends GdUnitTestSuite + +## NEO-74: `RecipeDefinitionsClient` GET parse + `recipes_ready` signal. + +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") diff --git a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md index 966e69e..f15bf8c 100644 --- a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md +++ b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md @@ -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 diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 1d4fd90..f56ec69 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -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-37–NEO-41, NEO-42–NEO-44 | -| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **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-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **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) | --- diff --git a/docs/manual-qa/NEO-74.md b/docs/manual-qa/NEO-74.md new file mode 100644 index 0000000..974808e --- /dev/null +++ b/docs/manual-qa/NEO-74.md @@ -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. diff --git a/docs/plans/E3S5-client-prototype-backlog.md b/docs/plans/E3S5-client-prototype-backlog.md index 62d9717..751206e 100644 --- a/docs/plans/E3S5-client-prototype-backlog.md +++ b/docs/plans/E3S5-client-prototype-backlog.md @@ -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. --- diff --git a/docs/plans/NEO-74-implementation-plan.md b/docs/plans/NEO-74-implementation-plan.md index ea87f0e..beb15f4 100644 --- a/docs/plans/NEO-74-implementation-plan.md +++ b/docs/plans/NEO-74-implementation-plan.md @@ -48,9 +48,16 @@ No other blocking decisions — list **all eight** prototype recipes per [E3S5 k ## Acceptance criteria checklist -- [ ] **`refine_scrap_standard`** and **`make_field_stim_mk0`** craftable from UI when materials allow. -- [ ] Denied craft shows **`reasonCode`** on **`CraftFeedbackLabel`**; inventory unchanged (verify via GET / no auto-refresh on deny). -- [ ] **Refine** skill level/XP visible on **`SkillProgressionLabel`** and increases after successful craft. +- [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