Merge pull request #108 from ViPro-Technologies/NEO-74-e3m2-client-craft-ui-recipe-list
NEO-74: Client craft UI + recipe list (E3S5-03)pull/109/head
commit
6867ab4e55
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
extends Node
|
||||
|
||||
## NEO-74: prototype HTTP client for [code]POST /game/players/{id}/craft[/code] (NEO-70).
|
||||
|
||||
signal craft_result_received(recipe_id: String, result: Dictionary)
|
||||
signal craft_request_failed(recipe_id: String, reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _current_recipe_id: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func is_busy() -> bool:
|
||||
return _busy
|
||||
|
||||
|
||||
## Returns [code]true[/code] when the HTTP POST was queued.
|
||||
func request_craft(recipe_id: String, quantity: int = 1) -> bool:
|
||||
if _busy:
|
||||
return false
|
||||
var rid := recipe_id.strip_edges()
|
||||
if rid.is_empty():
|
||||
return false
|
||||
_current_recipe_id = rid
|
||||
_busy = true
|
||||
var payload: Dictionary = {
|
||||
"schemaVersion": SCHEMA_VERSION,
|
||||
"recipeId": rid,
|
||||
"quantity": quantity,
|
||||
}
|
||||
var url := "%s/game/players/%s/craft" % [_base_root(), _player_path_segment()]
|
||||
var headers := PackedStringArray(["Content-Type: application/json"])
|
||||
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if err != OK:
|
||||
_busy = false
|
||||
_current_recipe_id = ""
|
||||
push_warning("CraftClient: POST failed to start (%s)" % err)
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_craft_response_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
if not root.has("success"):
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
var recipe_echo: String = _current_recipe_id
|
||||
_current_recipe_id = ""
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("CraftClient: %s" % reason)
|
||||
craft_request_failed.emit(recipe_echo, reason)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("CraftClient: %s" % reason_code)
|
||||
craft_request_failed.emit(recipe_echo, reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var parsed: Variant = parse_craft_response_json(text)
|
||||
if parsed == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("CraftClient: %s" % reason_json)
|
||||
craft_request_failed.emit(recipe_echo, reason_json)
|
||||
return
|
||||
var data: Dictionary = parsed as Dictionary
|
||||
var success: bool = bool(data.get("success", false))
|
||||
if not success:
|
||||
var reason_variant: Variant = data.get("reasonCode", "")
|
||||
var reason_str: String = reason_variant as String if reason_variant is String else ""
|
||||
push_warning("craft_denied reasonCode=%s" % reason_str)
|
||||
craft_result_received.emit(recipe_echo, data.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74craftclient01
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
extends Control
|
||||
|
||||
## NEO-74: scrollable prototype recipe list with per-row Craft buttons.
|
||||
|
||||
signal craft_requested(recipe_id: String)
|
||||
|
||||
var _item_defs_client: Node = null
|
||||
var _craft_buttons: Array[Button] = []
|
||||
|
||||
@onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer
|
||||
|
||||
|
||||
func setup(item_defs_client: Node) -> void:
|
||||
_item_defs_client = item_defs_client
|
||||
|
||||
|
||||
func populate(recipes: Array) -> void:
|
||||
if not is_instance_valid(_vbox):
|
||||
return
|
||||
for child in _vbox.get_children():
|
||||
child.queue_free()
|
||||
_craft_buttons.clear()
|
||||
for row_variant in recipes:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var recipe: Dictionary = row_variant
|
||||
var recipe_id: String = str(recipe.get("id", ""))
|
||||
if recipe_id.is_empty():
|
||||
continue
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var info := Label.new()
|
||||
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
info.text = _format_recipe_line(recipe)
|
||||
row.add_child(info)
|
||||
var craft_btn := Button.new()
|
||||
craft_btn.text = "Craft"
|
||||
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id))
|
||||
row.add_child(craft_btn)
|
||||
_vbox.add_child(row)
|
||||
_craft_buttons.append(craft_btn)
|
||||
|
||||
|
||||
func set_craft_busy(busy: bool) -> void:
|
||||
for btn in _craft_buttons:
|
||||
if is_instance_valid(btn):
|
||||
btn.disabled = busy
|
||||
|
||||
|
||||
func _format_recipe_line(recipe: Dictionary) -> String:
|
||||
var recipe_id: String = str(recipe.get("id", ""))
|
||||
var display_name: String = str(recipe.get("displayName", recipe_id))
|
||||
var inputs_line: String = _format_inputs(recipe.get("inputs", []))
|
||||
return "%s (%s)\nneeds: %s" % [display_name, recipe_id, inputs_line]
|
||||
|
||||
|
||||
func _format_inputs(raw_inputs: Variant) -> String:
|
||||
if raw_inputs == null or not raw_inputs is Array:
|
||||
return "—"
|
||||
var parts: PackedStringArray = PackedStringArray()
|
||||
for row_variant in raw_inputs as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
var item_id: String = str(row.get("itemId", ""))
|
||||
var qty: int = int(row.get("quantity", 0))
|
||||
if item_id.is_empty() or qty <= 0:
|
||||
continue
|
||||
parts.append("%d× %s" % [qty, _item_display_name(item_id)])
|
||||
if parts.is_empty():
|
||||
return "—"
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
func _item_display_name(item_id: String) -> String:
|
||||
if is_instance_valid(_item_defs_client) and _item_defs_client.has_method("display_name_for"):
|
||||
return str(_item_defs_client.call("display_name_for", item_id))
|
||||
return item_id
|
||||
|
||||
|
||||
func _on_craft_pressed(recipe_id: String) -> void:
|
||||
craft_requested.emit(recipe_id)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74craftpanel01
|
||||
|
|
@ -45,6 +45,7 @@ const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
|
|||
const COOLDOWN_REASON_ON_CD := "on_cooldown"
|
||||
const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
|
||||
const SALVAGE_SKILL_ID := "salvage"
|
||||
const REFINE_SKILL_ID := "refine"
|
||||
|
||||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -85,6 +86,8 @@ var _progression_error: String = ""
|
|||
var _gather_pre_scrap_qty: int = 0
|
||||
var _gather_pending_interactable_id: String = ""
|
||||
var _gather_awaiting_inventory_finalize: bool = false
|
||||
var _cached_recipe_rows: Array = []
|
||||
var _recipe_defs_error: String = ""
|
||||
|
||||
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
|
||||
@onready var _world: Node3D = $World
|
||||
|
|
@ -101,10 +104,14 @@ var _gather_awaiting_inventory_finalize: bool = false
|
|||
@onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
|
||||
@onready var _inventory_label: Label = $UICanvas/InventoryLabel
|
||||
@onready var _gather_feedback_label: Label = $UICanvas/GatherFeedbackLabel
|
||||
@onready var _craft_feedback_label: Label = $UICanvas/CraftFeedbackLabel
|
||||
@onready var _skill_progression_label: Label = $UICanvas/SkillProgressionLabel
|
||||
@onready var _craft_recipe_panel: Node = $UICanvas/CraftRecipePanel
|
||||
@onready var _inventory_client: Node = $InventoryClient
|
||||
@onready var _item_defs_client: Node = $ItemDefinitionsClient
|
||||
@onready var _skill_progression_client: Node = $SkillProgressionClient
|
||||
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
|
||||
@onready var _craft_client: Node = $CraftClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
@onready var _interaction_client: Node = $InteractionRequestClient
|
||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||
|
|
@ -149,6 +156,7 @@ func _ready() -> void:
|
|||
_setup_inventory_sync()
|
||||
_setup_skill_progression_sync()
|
||||
_setup_gather_interact_feedback()
|
||||
_setup_craft_ui()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
|
|
@ -358,6 +366,7 @@ func _apply_authority_http_config_to_client(client: Node) -> void:
|
|||
|
||||
|
||||
func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
|
||||
_populate_craft_recipe_panel_if_ready()
|
||||
if not _inventory_error.is_empty():
|
||||
return
|
||||
if not _last_inventory_snapshot.is_empty():
|
||||
|
|
@ -504,12 +513,18 @@ func _render_skill_progression_label() -> void:
|
|||
_skill_progression_label.text = "%s\nLoading…" % header
|
||||
return
|
||||
var salvage_row: Dictionary = _skill_row(SALVAGE_SKILL_ID)
|
||||
if salvage_row.is_empty():
|
||||
_skill_progression_label.text = "%s\nsalvage: — (missing row)" % header
|
||||
var refine_row: Dictionary = _skill_row(REFINE_SKILL_ID)
|
||||
if salvage_row.is_empty() or refine_row.is_empty():
|
||||
_skill_progression_label.text = "%s\nsalvage/refine: — (missing row)" % header
|
||||
return
|
||||
var level: int = int(salvage_row.get("level", 0))
|
||||
var xp: int = int(salvage_row.get("xp", 0))
|
||||
_skill_progression_label.text = "%s\nsalvage: level %d, xp %d" % [header, level, xp]
|
||||
var salvage_level: int = int(salvage_row.get("level", 0))
|
||||
var salvage_xp: int = int(salvage_row.get("xp", 0))
|
||||
var refine_level: int = int(refine_row.get("level", 0))
|
||||
var refine_xp: int = int(refine_row.get("xp", 0))
|
||||
_skill_progression_label.text = (
|
||||
"%s\nsalvage: level %d, xp %d\nrefine: level %d, xp %d"
|
||||
% [header, salvage_level, salvage_xp, refine_level, refine_xp]
|
||||
)
|
||||
|
||||
|
||||
func _skill_row(skill_id: String) -> Dictionary:
|
||||
|
|
@ -528,6 +543,111 @@ func _render_gather_feedback_label(text: String = "Gather: —") -> void:
|
|||
_gather_feedback_label.text = text
|
||||
|
||||
|
||||
func _setup_craft_ui() -> void:
|
||||
# NEO-74: recipe defs + craft POST + scrollable recipe panel.
|
||||
_apply_authority_http_config_to_client(_recipe_defs_client)
|
||||
_apply_authority_http_config_to_client(_craft_client)
|
||||
if _craft_recipe_panel.has_method("setup"):
|
||||
_craft_recipe_panel.call("setup", _item_defs_client)
|
||||
if _recipe_defs_client.has_signal("recipes_ready"):
|
||||
_recipe_defs_client.connect("recipes_ready", Callable(self, "_on_recipes_ready"))
|
||||
if _recipe_defs_client.has_signal("recipes_sync_failed"):
|
||||
_recipe_defs_client.connect(
|
||||
"recipes_sync_failed", Callable(self, "_on_recipes_sync_failed")
|
||||
)
|
||||
if _craft_recipe_panel.has_signal("craft_requested"):
|
||||
_craft_recipe_panel.connect("craft_requested", Callable(self, "_on_craft_requested"))
|
||||
if _craft_client.has_signal("craft_result_received"):
|
||||
_craft_client.connect("craft_result_received", Callable(self, "_on_craft_result_received"))
|
||||
if _craft_client.has_signal("craft_request_failed"):
|
||||
_craft_client.connect("craft_request_failed", Callable(self, "_on_craft_request_failed"))
|
||||
_render_craft_feedback_label()
|
||||
if _recipe_defs_client.has_method("request_sync_from_server"):
|
||||
_recipe_defs_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _on_recipes_ready(recipes: Array) -> void:
|
||||
if not _recipe_defs_error.is_empty():
|
||||
_render_craft_feedback_label()
|
||||
_recipe_defs_error = ""
|
||||
_cached_recipe_rows = recipes.duplicate(true)
|
||||
_populate_craft_recipe_panel_if_ready()
|
||||
|
||||
|
||||
func _on_recipes_sync_failed(reason: String) -> void:
|
||||
_recipe_defs_error = reason
|
||||
_cached_recipe_rows = []
|
||||
_render_craft_feedback_label("Recipes: error — %s" % reason)
|
||||
|
||||
|
||||
func _populate_craft_recipe_panel_if_ready() -> void:
|
||||
if _cached_recipe_rows.is_empty():
|
||||
return
|
||||
if is_instance_valid(_craft_recipe_panel) and _craft_recipe_panel.has_method("populate"):
|
||||
_craft_recipe_panel.call("populate", _cached_recipe_rows)
|
||||
|
||||
|
||||
func _on_craft_requested(recipe_id: String) -> void:
|
||||
if not is_instance_valid(_craft_client):
|
||||
return
|
||||
if _craft_client.has_method("is_busy") and bool(_craft_client.call("is_busy")):
|
||||
return
|
||||
if _craft_recipe_panel.has_method("set_craft_busy"):
|
||||
_craft_recipe_panel.call("set_craft_busy", true)
|
||||
if _craft_client.has_method("request_craft"):
|
||||
var started: bool = bool(_craft_client.call("request_craft", recipe_id, 1))
|
||||
if not started and _craft_recipe_panel.has_method("set_craft_busy"):
|
||||
_craft_recipe_panel.call("set_craft_busy", false)
|
||||
|
||||
|
||||
func _on_craft_result_received(_recipe_id: String, result: Dictionary) -> void:
|
||||
if _craft_recipe_panel.has_method("set_craft_busy"):
|
||||
_craft_recipe_panel.call("set_craft_busy", false)
|
||||
if bool(result.get("success", false)):
|
||||
_render_craft_feedback_label(_format_craft_success_line(result.get("outputsGranted", [])))
|
||||
_request_inventory_refresh()
|
||||
if (
|
||||
is_instance_valid(_skill_progression_client)
|
||||
and _skill_progression_client.has_method("request_sync_from_server")
|
||||
):
|
||||
_skill_progression_client.call("request_sync_from_server")
|
||||
return
|
||||
var rc := str(result.get("reasonCode", "")).strip_edges()
|
||||
if rc.is_empty():
|
||||
_render_craft_feedback_label("Craft: denied (no reasonCode)")
|
||||
else:
|
||||
_render_craft_feedback_label("Craft: denied — %s" % rc)
|
||||
|
||||
|
||||
func _on_craft_request_failed(_recipe_id: String, reason: String) -> void:
|
||||
if _craft_recipe_panel.has_method("set_craft_busy"):
|
||||
_craft_recipe_panel.call("set_craft_busy", false)
|
||||
_render_craft_feedback_label("Craft: failed — %s" % reason)
|
||||
|
||||
|
||||
func _format_craft_success_line(raw_outputs: Variant) -> String:
|
||||
if raw_outputs == null or not raw_outputs is Array:
|
||||
return "Craft: ok (no outputsGranted)"
|
||||
var parts: PackedStringArray = PackedStringArray()
|
||||
for row_variant in raw_outputs as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
var item_id: String = str(row.get("itemId", ""))
|
||||
var qty: int = int(row.get("quantity", 0))
|
||||
if item_id.is_empty() or qty <= 0:
|
||||
continue
|
||||
parts.append("+%d %s" % [qty, _inventory_item_label(item_id)])
|
||||
if parts.is_empty():
|
||||
return "Craft: ok (empty outputsGranted)"
|
||||
return "Craft: %s" % ", ".join(parts)
|
||||
|
||||
|
||||
func _render_craft_feedback_label(text: String = "Craft: —") -> void:
|
||||
if is_instance_valid(_craft_feedback_label):
|
||||
_craft_feedback_label.text = text
|
||||
|
||||
|
||||
func _clear_gather_session() -> void:
|
||||
_gather_pending_interactable_id = ""
|
||||
_gather_awaiting_inventory_finalize = false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
extends Node
|
||||
|
||||
## NEO-74: fetches [code]GET /game/world/recipe-definitions[/code] (NEO-68).
|
||||
## Caches ordered recipe rows for craft UI.
|
||||
|
||||
signal recipes_ready(recipes: Array)
|
||||
signal recipes_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _recipes: Array = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/world/recipe-definitions" % _base_root()
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("RecipeDefinitionsClient: %s" % reason)
|
||||
_busy = false
|
||||
recipes_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
static func parse_recipes_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
var recipes: Variant = root.get("recipes", null)
|
||||
if recipes == null or not recipes is Array:
|
||||
return null
|
||||
return recipes as Array
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("RecipeDefinitionsClient: %s" % reason)
|
||||
recipes_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("RecipeDefinitionsClient: %s" % reason_code)
|
||||
recipes_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var recipes: Variant = parse_recipes_json(text)
|
||||
if recipes == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("RecipeDefinitionsClient: %s" % reason_json)
|
||||
recipes_sync_failed.emit(reason_json)
|
||||
return
|
||||
_recipes = (recipes as Array).duplicate(true)
|
||||
recipes_ready.emit(_recipes.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74recipedef01
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-74: `CraftClient` POST payload + success/deny parse (craft panel wiring tested separately).
|
||||
|
||||
const CraftClient := preload("res://scripts/craft_client.gd")
|
||||
|
||||
var _failed_reason: String = ""
|
||||
var _failed_recipe_id: String = ""
|
||||
|
||||
|
||||
func _capture_failed(recipe_id: String, reason: String) -> void:
|
||||
_failed_recipe_id = recipe_id
|
||||
_failed_reason = reason
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var last_body: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
last_body = request_data
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _success_craft_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"success":true,"reasonCode":null,'
|
||||
+ '"inputsConsumed":[{"itemId":"scrap_metal_bulk","quantity":5}],'
|
||||
+ '"outputsGranted":[{"itemId":"refined_plate_stock","quantity":1}],'
|
||||
+ '"xpGrantSummary":{"skillId":"refine","amount":10,"sourceKind":"craft_refine"}}'
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = CraftClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_request_craft_posts_recipe_id_and_schema_version() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _success_craft_json()
|
||||
var c := _make_client(transport)
|
||||
# Act
|
||||
var started: bool = bool(c.call("request_craft", "refine_scrap_standard", 1))
|
||||
# Assert
|
||||
assert_that(started).is_true()
|
||||
assert_that(transport.last_url).contains("/craft")
|
||||
var parsed: Variant = JSON.parse_string(transport.last_body)
|
||||
assert_that(parsed is Dictionary).is_true()
|
||||
var body: Dictionary = parsed as Dictionary
|
||||
assert_that(int(body.get("schemaVersion", -1))).is_equal(1)
|
||||
assert_that(str(body.get("recipeId", ""))).is_equal("refine_scrap_standard")
|
||||
assert_that(int(body.get("quantity", -1))).is_equal(1)
|
||||
|
||||
|
||||
func test_success_response_emits_craft_result_received() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _success_craft_json()
|
||||
var c := _make_client(transport)
|
||||
var got: Array = []
|
||||
c.connect(
|
||||
"craft_result_received",
|
||||
func(recipe_id: String, result: Dictionary) -> void: got.append([recipe_id, result])
|
||||
)
|
||||
# Act
|
||||
c.call("request_craft", "refine_scrap_standard")
|
||||
# Assert
|
||||
assert_that(got.size()).is_equal(1)
|
||||
assert_that(str(got[0][0])).is_equal("refine_scrap_standard")
|
||||
var result: Dictionary = got[0][1]
|
||||
assert_that(bool(result.get("success", false))).is_true()
|
||||
var outputs: Variant = result.get("outputsGranted", null)
|
||||
assert_that(outputs is Array).is_true()
|
||||
assert_that((outputs as Array).size()).is_equal(1)
|
||||
|
||||
|
||||
func test_deny_response_emits_craft_result_with_reason_code() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = (
|
||||
'{"schemaVersion":1,"success":false,"reasonCode":"insufficient_materials",'
|
||||
+ '"inputsConsumed":[],"outputsGranted":[]}'
|
||||
)
|
||||
var c := _make_client(transport)
|
||||
var got: Array = []
|
||||
c.connect(
|
||||
"craft_result_received",
|
||||
func(_recipe_id: String, result: Dictionary) -> void: got.append(result)
|
||||
)
|
||||
# Act
|
||||
c.call("request_craft", "refine_scrap_standard")
|
||||
# Assert
|
||||
var result: Dictionary = got[0]
|
||||
assert_that(bool(result.get("success", true))).is_false()
|
||||
assert_that(str(result.get("reasonCode", ""))).is_equal("insufficient_materials")
|
||||
|
||||
|
||||
func test_http_error_emits_craft_request_failed() -> void:
|
||||
# Arrange
|
||||
_failed_reason = ""
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var c := _make_client(transport)
|
||||
c.connect("craft_request_failed", Callable(self, "_capture_failed"))
|
||||
# Act
|
||||
c.call("request_craft", "refine_scrap_standard")
|
||||
# Assert
|
||||
assert_that(_failed_reason).contains("404")
|
||||
|
||||
|
||||
func test_request_craft_clears_busy_after_sync_mock_response() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _success_craft_json()
|
||||
var c := _make_client(transport)
|
||||
# Act
|
||||
var started: bool = bool(c.call("request_craft", "refine_scrap_standard", 1))
|
||||
# Assert
|
||||
assert_that(started).is_true()
|
||||
assert_that(bool(c.call("is_busy"))).is_false()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74craftcltest
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-74: successful craft triggers inventory + skill refresh; deny skips refresh.
|
||||
|
||||
const CraftClient := preload("res://scripts/craft_client.gd")
|
||||
|
||||
|
||||
class MockCraftTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var body_json: String = '{"schemaVersion":1,"success":true,"outputsGranted":[]}'
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class SpySyncClient:
|
||||
extends Node
|
||||
var sync_calls: int = 0
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
sync_calls += 1
|
||||
|
||||
|
||||
class CraftRefreshHarness:
|
||||
extends Node
|
||||
var inventory_sync_calls: int = 0
|
||||
var skill_sync_calls: int = 0
|
||||
var _inventory: SpySyncClient
|
||||
var _skill: SpySyncClient
|
||||
var _craft: Node
|
||||
|
||||
func setup(parent: Node, transport: Node) -> void:
|
||||
_inventory = SpySyncClient.new()
|
||||
_skill = SpySyncClient.new()
|
||||
_craft = CraftClient.new()
|
||||
_craft.set("injected_http", transport)
|
||||
parent.add_child(_craft)
|
||||
parent.add_child(_inventory)
|
||||
parent.add_child(_skill)
|
||||
_craft.connect("craft_result_received", Callable(self, "_on_craft_result"))
|
||||
|
||||
func _on_craft_result(_recipe_id: String, result: Dictionary) -> void:
|
||||
if not bool(result.get("success", false)):
|
||||
return
|
||||
_inventory.request_sync_from_server()
|
||||
_skill.request_sync_from_server()
|
||||
|
||||
func post_craft(recipe_id: String) -> void:
|
||||
_craft.call("request_craft", recipe_id)
|
||||
|
||||
|
||||
func test_successful_craft_triggers_both_refreshes() -> void:
|
||||
# Arrange
|
||||
var transport := MockCraftTransport.new()
|
||||
var harness := CraftRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._craft)
|
||||
# Act
|
||||
harness.post_craft("refine_scrap_standard")
|
||||
# Assert
|
||||
assert_signal(harness._craft).is_emitted("craft_result_received")
|
||||
assert_that(harness._inventory.sync_calls).is_equal(1)
|
||||
assert_that(harness._skill.sync_calls).is_equal(1)
|
||||
|
||||
|
||||
func test_denied_craft_skips_refreshes() -> void:
|
||||
# Arrange
|
||||
var transport := MockCraftTransport.new()
|
||||
transport.body_json = (
|
||||
'{"schemaVersion":1,"success":false,"reasonCode":"insufficient_materials",'
|
||||
+ '"inputsConsumed":[],"outputsGranted":[]}'
|
||||
)
|
||||
var harness := CraftRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
# Act
|
||||
harness.post_craft("refine_scrap_standard")
|
||||
# Assert
|
||||
assert_that(harness._inventory.sync_calls).is_equal(0)
|
||||
assert_that(harness._skill.sync_calls).is_equal(0)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74craftfbtest
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-74: `RecipeDefinitionsClient` GET parse + `recipes_ready` signal.
|
||||
## `main.gd` repopulates craft panel on item `definitions_ready` even when inventory GET failed.
|
||||
|
||||
const RecipeDefinitionsClient := preload("res://scripts/recipe_definitions_client.gd")
|
||||
|
||||
var _recipes_capture: Array = []
|
||||
|
||||
|
||||
func _capture_recipes(recipes: Array) -> void:
|
||||
_recipes_capture = recipes
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _two_recipes_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"recipes":['
|
||||
+ '{"id":"make_armor_quick","displayName":"Quick Armor","recipeKind":"make",'
|
||||
+ '"requiredSkillId":"refine","inputs":[],"outputs":[]},'
|
||||
+ '{"id":"refine_scrap_standard","displayName":"Refine Scrap (Standard)",'
|
||||
+ '"recipeKind":"process","requiredSkillId":"refine",'
|
||||
+ '"inputs":[{"itemId":"scrap_metal_bulk","quantity":5}],'
|
||||
+ '"outputs":[{"itemId":"refined_plate_stock","quantity":1}]}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = RecipeDefinitionsClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_recipes_json_reads_array() -> void:
|
||||
# Arrange
|
||||
var json := _two_recipes_json()
|
||||
# Act
|
||||
var recipes: Variant = RecipeDefinitionsClient.parse_recipes_json(json)
|
||||
# Assert
|
||||
assert_that(recipes is Array).is_true()
|
||||
assert_that((recipes as Array).size()).is_equal(2)
|
||||
|
||||
|
||||
func test_request_sync_emits_recipes_ready_in_order() -> void:
|
||||
# Arrange
|
||||
_recipes_capture = []
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _two_recipes_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("recipes_ready", Callable(self, "_capture_recipes"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("recipes_ready")
|
||||
assert_that(_recipes_capture.size()).is_equal(2)
|
||||
assert_that(str(_recipes_capture[0].get("id", ""))).is_equal("make_armor_quick")
|
||||
assert_that(transport.last_url).contains("/recipe-definitions")
|
||||
|
||||
|
||||
func test_schema_mismatch_emits_recipes_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = '{"schemaVersion":99,"recipes":[]}'
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("recipes_sync_failed")
|
||||
|
||||
|
||||
func test_http_error_emits_recipes_sync_failed_with_status() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 503
|
||||
var c := _make_client(transport)
|
||||
var got: Array = []
|
||||
c.connect("recipes_sync_failed", func(reason: String) -> void: got.append(reason))
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(got.size()).is_equal(1)
|
||||
assert_that(str(got[0])).contains("503")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo74recipedefst
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
# NEO-74 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-74 |
|
||||
| **Title** | E3.M2: Client craft UI + recipe list (E3S5-03) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-74/e3m2-client-craft-ui-recipe-list-e3s5-03 |
|
||||
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 **Slice 5** (client 3 of 4) · backlog **E3S5-03** |
|
||||
| **Branch** | `NEO-74-e3m2-client-craft-ui-recipe-list` |
|
||||
| **Server deps** | [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) — `GET /game/world/recipe-definitions` (**Done**); [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) — `POST …/craft` (**Done**); [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — inventory HUD + refresh (**Done**); [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) — skill-progression GET (**Done**) |
|
||||
| **Client deps** | [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — `SkillProgressionClient`, gather refresh pattern (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) / [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — thin HTTP clients, `main.gd` wiring, debug HUD labels, GdUnit mock transport |
|
||||
| **Downstream** | [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) capstone gather→refine→make loop; optional HUD collapse |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Craft success copy** | What should success feedback show? | **`outputsGranted` from POST body** — e.g. `Craft: +1 {displayName}`; still GET inventory + skill-progression after success (E3S5 refresh policy). | **User:** POST **`outputsGranted`** summary. |
|
||||
| **Craft feedback label** | Dedicated label vs reuse gather? | **Dedicated `CraftFeedbackLabel`** below **`GatherFeedbackLabel`** — mirrors cast/gather separation. | **User:** dedicated **`CraftFeedbackLabel`**. |
|
||||
| **Recipe panel layout** | Where to place recipe list + Craft buttons? | **Left-column `ScrollContainer`** below **`SkillProgressionLabel`** — matches debug HUD stack; NEO-75 can collapse if crowded. | **User:** left scroll below skills. |
|
||||
|
||||
No other blocking decisions — list **all eight** prototype recipes per [E3S5 kickoff table](E3S5-client-prototype-backlog.md#kickoff-decisions-slice-5-client); qty **1** only (no spinner); deny copy **`Craft: denied — {reasonCode}`** mirrors gather; **`refine`** row added to existing **`SkillProgressionLabel`** (salvage row unchanged).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Player selects a prototype recipe and **`POST /game/players/{id}/craft`** from Godot; sees success/deny, updated inventory, and **refine** XP on HUD — not Output-panel-only proof.
|
||||
|
||||
**In scope (from Linear + [E3S5-03](E3S5-client-prototype-backlog.md#e3s5-03--client-craft-ui--recipe-list-e3m2)):**
|
||||
|
||||
- **`recipe_definitions_client.gd`:** GET world recipe defs (NEO-68); cache ordered list; emit **`recipes_ready`**.
|
||||
- **`craft_client.gd`:** POST `{ schemaVersion: 1, recipeId }` (qty **1** implicit); parse success/deny + **`reasonCode`** + I/O lists; emit **`craft_result_received`**.
|
||||
- **`craft_recipe_panel.gd`** (child under **`UICanvas`**): build scrollable recipe rows (id / **`displayName`**, inputs summary via **`ItemDefinitionsClient.display_name_for`**, **Craft** **`Button`** per row); signal **`craft_requested(recipe_id)`**.
|
||||
- **`CraftFeedbackLabel`:** success **`Craft: +N {displayName}`** from **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**; no refresh on deny.
|
||||
- Extend **`SkillProgressionLabel`:** add **`refine: level N, xp M`** row below salvage; boot hydrate + refresh after successful craft.
|
||||
- **`main.gd` craft path:** on **`craft_requested`** → **`craft_client.request_craft(recipe_id)`**; on success → paint feedback from POST outputs → **`inventory_client.request_sync_from_server()`** + **`skill_progression_client.request_sync_from_server()`**; on deny → feedback only (inventory unchanged per AC).
|
||||
- Boot: chain **`recipe_definitions_client.request_sync_from_server()`** after item defs (or parallel); panel populates on **`recipes_ready`**.
|
||||
- GdUnit: craft POST payload/parse; recipe defs parse; deny/success signal paths.
|
||||
- **`client/README.md`** craft UI subsection.
|
||||
- **`docs/manual-qa/NEO-74.md`**.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Station/bench world objects; craft quantity spinner; recipe filtering by skill level.
|
||||
- HUD collapse/toggle (NEO-75 optional).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] **`refine_scrap_standard`** and **`make_field_stim_mk0`** craftable from UI when materials allow.
|
||||
- [x] Denied craft shows **`reasonCode`** on **`CraftFeedbackLabel`**; inventory unchanged (verify via GET / no auto-refresh on deny).
|
||||
- [x] **Refine** skill level/XP visible on **`SkillProgressionLabel`** and increases after successful craft.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Clients:** `recipe_definitions_client.gd`, `craft_client.gd`; `craft_recipe_panel.gd` scroll list + **Craft** buttons.
|
||||
- **HUD:** `CraftFeedbackLabel` (POST **`outputsGranted`** / deny); **`SkillProgressionLabel`** salvage + **refine** rows; inventory refresh on craft success only.
|
||||
- **Tests:** `craft_client_test.gd`, `recipe_definitions_client_test.gd`, `craft_feedback_refresh_test.gd`.
|
||||
- **Docs:** `client/README.md` craft section; [`NEO-74` manual QA](../manual-qa/NEO-74.md).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`recipe_definitions_client.gd`**
|
||||
- Mirror **`item_definitions_client.gd`**: injectable HTTP, **`_busy`** guard, **`GET …/world/recipe-definitions`**.
|
||||
- **`parse_recipes_json(text)`** static: require **`schemaVersion` 1**, **`recipes`** array.
|
||||
- Cache **`Array`** of recipe dicts in server id order; emit **`recipes_ready(recipes)`**.
|
||||
- Helper **`recipe_by_id(id)`** for panel lookups.
|
||||
|
||||
2. **`craft_client.gd`**
|
||||
- Mirror **`ability_cast_client.gd`** POST pattern: injectable HTTP, **`_busy`** guard.
|
||||
- **`request_craft(recipe_id: String, quantity: int = 1) -> bool`**: POST body `{ "schemaVersion": 1, "recipeId": recipe_id, "quantity": quantity }` to **`…/players/{id}/craft`**.
|
||||
- **`parse_craft_response_json(text)`** static: require **`schemaVersion` 1**, **`success`** bool.
|
||||
- Signal **`craft_result_received(result: Dictionary)`** with parsed body (includes **`success`**, **`reasonCode`**, **`outputsGranted`**, **`inputsConsumed`**, optional **`xpGrantSummary`**).
|
||||
- On HTTP transport failure / non-2xx / JSON mismatch: **`craft_request_failed(recipe_id, reason)`** (mirror interaction client failure path).
|
||||
- **`push_warning`** on deny with stable reason (NEO-31 pattern).
|
||||
|
||||
3. **`craft_recipe_panel.gd`**
|
||||
- **`@export`** refs or **`setup(...)`** for **`ItemDefinitionsClient`** display-name lookup.
|
||||
- **`populate(recipes: Array)`**: clear **`ScrollContainer/VBoxContainer`** children; for each recipe row:
|
||||
- **`Label`**: **`{displayName}`** (`{id}`) + inputs line e.g. **`needs: 5× Scrap Metal Bulk`** (join **`inputs`** with **`display_name_for(itemId)`**).
|
||||
- **`Button`**: text **`Craft`**; pressed → emit **`craft_requested(recipe.get("id"))`**.
|
||||
- Disable buttons while **`craft_client`** busy (optional **`set_craft_busy(busy)`** from **`main`**).
|
||||
|
||||
4. **`main.gd` craft orchestration**
|
||||
- Constants: **`REFINE_SKILL_ID := "refine"`**.
|
||||
- State: **`_last_craft_recipe_id: String`**, **`_craft_busy`** (or delegate to client **`_busy`**).
|
||||
- **`_setup_craft_ui()`**: wire clients, panel, feedback label; connect **`craft_requested`**, **`craft_result_received`**, **`craft_request_failed`**.
|
||||
- Success handler:
|
||||
- Format **`outputsGranted`** rows → **`Craft: +{qty} {displayName}`** (multi-output: comma-join).
|
||||
- Trigger inventory + skill progression refresh (same as gather success).
|
||||
- Deny handler: **`Craft: denied — {reasonCode}`**; no GET refresh.
|
||||
- Extend **`_render_skill_progression_label()`**: salvage row + **`refine`** row (two lines under **`Skills:`** header).
|
||||
|
||||
5. **Scene layout (`main.tscn`)**
|
||||
- Add nodes: **`RecipeDefinitionsClient`**, **`CraftClient`**, **`CraftFeedbackLabel`**, **`CraftRecipePanel`** (with **`ScrollContainer` > `VBoxContainer`**).
|
||||
- **`CraftFeedbackLabel`:** below **`GatherFeedbackLabel`** (~offset_top 404); shift **`CooldownSlotsLabel`** down (~+28px) and cascade inventory/skills/panel offsets to avoid overlap.
|
||||
- **`CraftRecipePanel`:** below **`SkillProgressionLabel`** (~offset_top 728); fixed height scroll (~200–280px) for eight rows.
|
||||
- HTTP client nodes as siblings (same pattern as **`InventoryClient`**).
|
||||
|
||||
6. **Tests**
|
||||
- **`craft_client_test.gd`:** mock 200 success → **`success=true`**, **`outputsGranted`** parsed; mock 200 deny → **`success=false`**, **`reasonCode=insufficient_materials`**; verify POST URL + JSON body includes **`recipeId`**. AAA layout.
|
||||
- **`recipe_definitions_client_test.gd`:** mock 200 with two recipes → **`recipes_ready`** length + id order. AAA layout.
|
||||
- **`craft_feedback_refresh_test.gd`:** stub handler — craft allow signal → inventory + skill **`request_sync_from_server`** invoked; deny → refresh **not** invoked. AAA layout.
|
||||
|
||||
7. **Manual QA (`docs/manual-qa/NEO-74.md`)**
|
||||
- Fresh player: craft deny **`insufficient_materials`** for **`refine_scrap_standard`** → HUD reason + bag unchanged.
|
||||
- After gather ≥5 scrap: **`refine_scrap_standard`** success → feedback line, inventory shows **`refined_plate_stock`**, refine XP bumped.
|
||||
- With 2 refined + 1 scrap: **`make_field_stim_mk0`** success → **`field_stim_mk0`** in bag.
|
||||
- Deny paths spot-check: unknown recipe (if test hook), bag full (optional note — may need pre-fill).
|
||||
|
||||
8. **Docs on land**
|
||||
- Update [E3_M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes.
|
||||
- Check **E3S5-03** boxes in **`E3S5-client-prototype-backlog.md`**.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-74-implementation-plan.md` | This plan. |
|
||||
| `client/scripts/recipe_definitions_client.gd` | GET world recipe defs; **`recipes_ready`**; parse v1 **`recipes`** array. |
|
||||
| `client/scripts/recipe_definitions_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/scripts/craft_client.gd` | POST craft; **`craft_result_received`** / **`craft_request_failed`**. |
|
||||
| `client/scripts/craft_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/scripts/craft_recipe_panel.gd` | Scrollable recipe list + **Craft** buttons; **`craft_requested`** signal. |
|
||||
| `client/scripts/craft_recipe_panel.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/test/craft_client_test.gd` | GdUnit: POST payload, success/deny parse. AAA layout. |
|
||||
| `client/test/recipe_definitions_client_test.gd` | GdUnit: parse v1 recipes array. AAA layout. |
|
||||
| `client/test/craft_feedback_refresh_test.gd` | GdUnit: success → inventory + skill refresh; deny → no refresh. AAA layout. |
|
||||
| `docs/manual-qa/NEO-74.md` | Craft spine UI session: deny, refine, make stim, refine XP delta. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/main.gd` | Boot wiring for recipe defs + craft clients; craft orchestration; **`CraftFeedbackLabel`** render; **`refine`** skill row; authority HTTP config for new clients. |
|
||||
| `client/scenes/main.tscn` | Add **`RecipeDefinitionsClient`**, **`CraftClient`**, **`CraftFeedbackLabel`**, **`CraftRecipePanel`**; adjust HUD vertical offsets. |
|
||||
| `client/README.md` | Craft UI subsection: recipe panel, craft POST, feedback labels, boot hydrate, server deps. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `client/test/craft_client_test.gd` | Mock 200 success → **`craft_result_received`** with **`outputsGranted`**; mock deny → **`reasonCode`**; POST body **`recipeId`** + **`schemaVersion` 1**. AAA layout. |
|
||||
| `client/test/recipe_definitions_client_test.gd` | Mock 200 → **`recipes_ready`** emits ordered array; schema mismatch → no emit. AAA layout. |
|
||||
| `client/test/craft_feedback_refresh_test.gd` | Craft success handler triggers inventory + skill **`request_sync_from_server`**; deny path skips refresh (spy/mock). AAA layout. |
|
||||
|
||||
No new **C#** tests (client-only). No new Bruno (server covered by NEO-68/NEO-70).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **HUD vertical crowding (8 recipes)** | Left scroll with ~200–280px max height; full collapse deferred to NEO-75. | **adopted** (kickoff) |
|
||||
| **Multi-output success copy** | Comma-join **`outputsGranted`** rows — prototype recipes have single output except batch variants. | **adopted** |
|
||||
| **Craft busy double-click** | Guard with **`craft_client._busy`**; disable panel buttons while in flight. | **adopted** |
|
||||
| **Deny without inventory refresh** | Do not call inventory GET on **`success=false`** — AC requires unchanged bag; manual QA verifies. | **adopted** |
|
||||
| **Item display names on recipe inputs** | Reuse landed **`ItemDefinitionsClient`** cache; fall back to raw **`itemId`**. | **adopted** |
|
||||
| **Slice 5 module docs** | Update alignment register when NEO-74 lands, not at kickoff. | **deferred** (implementation) |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Craft success copy:** **`outputsGranted`** from POST response formatted on **`CraftFeedbackLabel`** (user confirmed).
|
||||
- **Craft feedback:** dedicated **`CraftFeedbackLabel`** below gather (user confirmed).
|
||||
- **Recipe panel:** left-column **`ScrollContainer`** below **`SkillProgressionLabel`** (user confirmed).
|
||||
- **Recipe scope:** all **eight** prototype recipes with qty-**1** **Craft** buttons (E3S5 backlog).
|
||||
- **Skill HUD:** add **`refine`** row to **`SkillProgressionLabel`**; refresh after successful craft.
|
||||
- **Refresh policy:** GET inventory + skill-progression on craft success only; no GET on deny.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Code review — NEO-74 client craft UI + recipe list
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-74-e3m2-client-craft-ui-recipe-list` · commits `dff58ad`–`c16b610` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
**Follow-up:** Review feedback addressed (recipe-sync failure HUD, test `.uid` companions, unused `_scroll` binding).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve** — implementation matches the adopted plan and E3S5-03 acceptance criteria; GdUnit suites pass. Follow-up wired **`recipes_sync_failed`** HUD and added test **`.uid`** companions.
|
||||
|
||||
## Summary
|
||||
|
||||
The branch adds **`recipe_definitions_client.gd`**, **`craft_client.gd`**, and **`craft_recipe_panel.gd`**, wires them from **`main.gd`** with **`CraftFeedbackLabel`**, a scrollable eight-recipe panel, **refine** row on **`SkillProgressionLabel`**, and post-success inventory + skill refresh (deny leaves bag unchanged). Three GdUnit suites cover POST payload/parse, recipe GET parse, and refresh policy. Manual QA, plan reconciliation, E3.M2 module note, alignment register, and E3S5 backlog checkboxes are updated. Design follows NEO-72/73/31 patterns (thin HTTP clients, `_busy` guard, injectable transport, authority config copy, dedicated feedback label). HTTP handlers correctly use **`PackedStringArray`** headers (NEO-72 lesson applied).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-74-implementation-plan.md`](../plans/NEO-74-implementation-plan.md) | **Matches** — kickoff decisions (POST **`outputsGranted`** copy, dedicated **`CraftFeedbackLabel`**, left scroll panel, qty **1**, deny refresh policy) reflected; acceptance checklist and reconciliation complete. |
|
||||
| [`docs/plans/E3S5-client-prototype-backlog.md`](../plans/E3S5-client-prototype-backlog.md) · **E3S5-03** | **Matches** — acceptance criteria checked; scope/out-of-scope honored; does not claim capstone (NEO-75) complete. |
|
||||
| [`docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) | **Matches** — NEO-74 client slice **landed** note added. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M2 row notes NEO-74 landed; NEO-75 cited as remaining Slice 5 client work. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — client sends craft **intent** via POST; inventory/skill display refreshed from server GET after success. |
|
||||
| [`docs/manual-qa/NEO-74.md`](../manual-qa/NEO-74.md) | **Matches** — deny/success/refine XP walkthrough; optional **`inventory_full`** spot-check. |
|
||||
| [`client/README.md`](../../client/README.md) craft UI section | **Matches** — endpoints, HUD labels, refresh policy; automated test Scope lists NEO-74 suites. |
|
||||
| Full-stack epic decomposition | **Matches** — E3S5-03 **client** counterpart for server NEO-68/NEO-70; Godot manual QA present. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Wire `recipes_sync_failed` in `main.gd`** — `RecipeDefinitionsClient` emits **`recipes_sync_failed`** (tested in GdUnit) but **`main.gd`** never connects it. On boot failure the recipe panel stays empty with no HUD line (inventory/skill clients show errors). Connect to **`CraftFeedbackLabel`** or **`push_warning`** + a one-line error for parity with **`inventory_sync_failed`** / **`progression_sync_failed`**.~~ **Done.** **`_on_recipes_sync_failed`** paints **`Recipes: error — …`** on **`CraftFeedbackLabel`**.
|
||||
|
||||
2. ~~**Add `.gd.uid` companions for new GdUnit test scripts** — `craft_client_test.gd`, `recipe_definitions_client_test.gd`, and `craft_feedback_refresh_test.gd` have no tracked **`*.gd.uid`** files (NEO-73 established this for new tests under `client/test/`). Generate in Godot and commit alongside the tests per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md).~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`craft_recipe_panel.gd`** declares **`@onready var _scroll`** but only uses **`_vbox`** — remove unused binding or use **`_scroll`** for max-height policy.~~ **Done.**
|
||||
- Nit: **`craft_feedback_refresh_test.gd`** harness duplicates refresh logic from **`main.gd`** (intentional isolation) — acceptable; no **`main.gd`** integration test remains (same gap as NEO-73 gather refresh test).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Server (separate terminal)
|
||||
cd server/NeonSprawl.Server && dotnet run
|
||||
|
||||
# NEO-74 GdUnit suites (from client/)
|
||||
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
|
||||
-a res://test/craft_client_test.gd \
|
||||
-a res://test/recipe_definitions_client_test.gd \
|
||||
-a res://test/craft_feedback_refresh_test.gd
|
||||
```
|
||||
|
||||
Manual: follow [`docs/manual-qa/NEO-74.md`](../manual-qa/NEO-74.md) — deny **`insufficient_materials`**, **`refine_scrap_standard`** success, **`make_field_stim_mk0`**, **`refine`** XP delta without pressing **I** on success.
|
||||
Loading…
Reference in New Issue