NEO-74: Implement client craft UI, recipe list, and tests.

Add recipe-definitions and craft HTTP clients, scrollable recipe panel with
Craft buttons, CraftFeedbackLabel, refine skill HUD row, GdUnit coverage,
manual QA, and plan/module doc reconciliation.
pull/108/head
VinPropane 2026-05-24 19:56:38 -04:00
parent dff58adfe8
commit c16b610468
17 changed files with 836 additions and 23 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). 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) ### Manual check (NEO-24)
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`. 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. **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. **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/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/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/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"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"]
geometry_collision_mask = 1 geometry_collision_mask = 1
@ -1101,6 +1104,12 @@ script = ExtResource("15_inventory")
[node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008] [node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008]
script = ExtResource("16_skill_prog") 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="UICanvas" type="CanvasLayer" parent="." unique_id=9000001]
[node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002] [node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002]
@ -1174,11 +1183,25 @@ theme_override_font_sizes/font_size = 14
autowrap_mode = 3 autowrap_mode = 3
text = "Gather: —" 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] [node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006]
offset_left = 8.0 offset_left = 8.0
offset_top = 408.0 offset_top = 436.0
offset_right = 520.0 offset_right = 520.0
offset_bottom = 484.0 offset_bottom = 512.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1) 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] [node name="InventoryLabel" type="Label" parent="UICanvas" unique_id=9000007]
offset_left = 8.0 offset_left = 8.0
offset_top = 496.0 offset_top = 524.0
offset_right = 520.0 offset_right = 520.0
offset_bottom = 644.0 offset_bottom = 672.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 1) 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] [node name="SkillProgressionLabel" type="Label" parent="UICanvas" unique_id=9000009]
offset_left = 8.0 offset_left = 8.0
offset_top = 656.0 offset_top = 684.0
offset_right = 520.0 offset_right = 520.0
offset_bottom = 720.0 offset_bottom = 768.0
grow_horizontal = 0 grow_horizontal = 0
grow_vertical = 0 grow_vertical = 0
theme_override_colors/font_color = Color(0.82, 0.9, 1, 1) 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 autowrap_mode = 3
text = "Skills: text = "Skills:
Loading…" 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,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))

View File

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

View File

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

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 COOLDOWN_REASON_ON_CD := "on_cooldown"
const SCRAP_METAL_BULK_ID := "scrap_metal_bulk" const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
const SALVAGE_SKILL_ID := "salvage" 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. ## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0 var _move_reject_msg_token: int = 0
@ -85,6 +86,7 @@ var _progression_error: String = ""
var _gather_pre_scrap_qty: int = 0 var _gather_pre_scrap_qty: int = 0
var _gather_pending_interactable_id: String = "" var _gather_pending_interactable_id: String = ""
var _gather_awaiting_inventory_finalize: bool = false var _gather_awaiting_inventory_finalize: bool = false
var _cached_recipe_rows: Array = []
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
@onready var _world: Node3D = $World @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 _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
@onready var _inventory_label: Label = $UICanvas/InventoryLabel @onready var _inventory_label: Label = $UICanvas/InventoryLabel
@onready var _gather_feedback_label: Label = $UICanvas/GatherFeedbackLabel @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 _skill_progression_label: Label = $UICanvas/SkillProgressionLabel
@onready var _craft_recipe_panel: Node = $UICanvas/CraftRecipePanel
@onready var _inventory_client: Node = $InventoryClient @onready var _inventory_client: Node = $InventoryClient
@onready var _item_defs_client: Node = $ItemDefinitionsClient @onready var _item_defs_client: Node = $ItemDefinitionsClient
@onready var _skill_progression_client: Node = $SkillProgressionClient @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 _target_client: Node = $TargetSelectionClient
@onready var _interaction_client: Node = $InteractionRequestClient @onready var _interaction_client: Node = $InteractionRequestClient
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
@ -149,6 +155,7 @@ func _ready() -> void:
_setup_inventory_sync() _setup_inventory_sync()
_setup_skill_progression_sync() _setup_skill_progression_sync()
_setup_gather_interact_feedback() _setup_gather_interact_feedback()
_setup_craft_ui()
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
@ -362,6 +369,7 @@ func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
return return
if not _last_inventory_snapshot.is_empty(): if not _last_inventory_snapshot.is_empty():
_render_inventory_label() _render_inventory_label()
_populate_craft_recipe_panel_if_ready()
func _on_inventory_received(snapshot: Dictionary) -> void: func _on_inventory_received(snapshot: Dictionary) -> void:
@ -504,12 +512,18 @@ func _render_skill_progression_label() -> void:
_skill_progression_label.text = "%s\nLoading…" % header _skill_progression_label.text = "%s\nLoading…" % header
return return
var salvage_row: Dictionary = _skill_row(SALVAGE_SKILL_ID) var salvage_row: Dictionary = _skill_row(SALVAGE_SKILL_ID)
if salvage_row.is_empty(): var refine_row: Dictionary = _skill_row(REFINE_SKILL_ID)
_skill_progression_label.text = "%s\nsalvage: — (missing row)" % header if salvage_row.is_empty() or refine_row.is_empty():
_skill_progression_label.text = "%s\nsalvage/refine: — (missing row)" % header
return return
var level: int = int(salvage_row.get("level", 0)) var salvage_level: int = int(salvage_row.get("level", 0))
var xp: int = int(salvage_row.get("xp", 0)) var salvage_xp: int = int(salvage_row.get("xp", 0))
_skill_progression_label.text = "%s\nsalvage: level %d, xp %d" % [header, level, xp] 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: 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 _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: func _clear_gather_session() -> void:
_gather_pending_interactable_id = "" _gather_pending_interactable_id = ""
_gather_awaiting_inventory_finalize = false _gather_awaiting_inventory_finalize = false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@
| **Module ID** | E3.M2 | | **Module ID** | E3.M2 |
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) | | **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
| **Stage target** | Prototype | | **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 ## 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. 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 ## 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.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.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 | | 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.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):** 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.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. **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.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** **Acceptance criteria**
- [ ] Player can **`refine_scrap_standard`** and **`make_field_stim_mk0`** from UI when materials allow. - [x] 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). - [x] Denied craft shows **`reasonCode`** on HUD; inventory unchanged (verify via GET).
- [ ] **Refine** skill XP visible and increases on successful craft. - [x] **Refine** skill XP visible and increases on successful craft.
--- ---

View File

@ -48,9 +48,16 @@ No other blocking decisions — list **all eight** prototype recipes per [E3S5 k
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] **`refine_scrap_standard`** and **`make_field_stim_mk0`** craftable from UI when materials allow. - [x] **`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). - [x] 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** 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 ## Technical approach