Merge pull request #107 from ViPro-Technologies/NEO-73-e3m1-client-gather-feedback-on-interact
NEO-73: Client gather feedback on interact (E3S5-02)pull/109/head
commit
8e04cfc67b
|
|
@ -82,7 +82,7 @@ If the server is **down**, boot **`GET`** fails silently (check Output for warni
|
|||
|
||||
On boot, **`InteractablesCatalogClient`** **`GET`s** **`/game/world/interactables`**; **`interactable_world_builder.gd`** spawns **walkable** prototype props plus **two** small glow markers per row under **`World/InteractionMarkers`**. **`interaction_radius_indicators.gd`** compares the snapped **`CharacterBody3D`** **`global_position`** to each descriptor’s **`anchor`** on **X/Z** with the same **`interactionRadius`** and inclusive **`<=`** rule as the server — **preview only**; **`POST /game/players/{id}/interact`** remains authoritative.
|
||||
|
||||
- **`InteractionRequestClient`**: **`interact`** (**E**) → **`prototype_terminal`**; **`interact_secondary`** (**R**) → **`prototype_resource_node_alpha`** (stable ids; must match `PrototypeInteractableRegistry.cs`). **Output** prints `allowed` / `reasonCode` per response (lines prefixed with `Interaction [<id>]: …`). Only one HTTP POST runs at a time; if you press **E** then **R** while the first request is still in flight, the **latest** id is remembered and sent **immediately after** the first completes (last-wins coalescing — not a silent drop).
|
||||
- **`InteractionRequestClient`**: **`interact`** (**E**) → **`prototype_terminal`**; **`interact_secondary`** (**R**) → **nearest in-range** **`resource_node`** from the interactables catalog (falls back to **`prototype_resource_node_alpha`** when none in range). **Output** prints `allowed` / `reasonCode` per response (lines prefixed with `Interaction [<id>]: …`). Only one HTTP POST runs at a time; if you press **E** then **R** while the first request is still in flight, the **latest** id is remembered and sent **immediately after** the first completes (last-wins coalescing — not a silent drop).
|
||||
- **Inspector:** match **`base_url`** on **`InteractablesCatalogClient`**, **`InteractionRequestClient`**, and **`PositionAuthorityClient`** to the running server.
|
||||
- **No server:** catalog **GET** fails → **Output** errors; props and glow markers do not spawn (expected prototype degradation).
|
||||
|
||||
|
|
@ -144,6 +144,16 @@ Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cas
|
|||
|
||||
Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
|
||||
|
||||
## Gather feedback on interact (NEO-73)
|
||||
|
||||
- **`POST /game/players/{id}/interact`** on **`resource_node`** kinds — server gather engine (NEO-63); no separate gather HTTP on client.
|
||||
- **`GET /game/players/{id}/skill-progression`** — **`salvage`** row on **`SkillProgressionLabel`**; boot hydrate + refresh after successful gather.
|
||||
- **Scripts:** `scripts/skill_progression_client.gd`, `scripts/prototype_interactable_picker.gd`; **`interaction_request_client.gd`** emits **`interaction_result_received`**; wired from `main.gd`.
|
||||
- **HUD:** `UICanvas/GatherFeedbackLabel` — success delta **`+N {displayName}`** or deny **`Gather: denied — {reasonCode}`** (including **`node_depleted`**). **`InventoryLabel`** refreshes automatically on successful gather (no **I**).
|
||||
- **R binding:** nearest in-range **`resource_node`** by horizontal distance to catalog anchors (same radius rule as glow preview).
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.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)`.
|
||||
|
|
@ -203,7 +213,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), **`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), **`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.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
[ext_resource type="Script" path="res://scripts/interactables_catalog_client.gd" id="13_ix_cat"]
|
||||
[ext_resource type="Script" path="res://scripts/item_definitions_client.gd" id="14_item_defs"]
|
||||
[ext_resource type="Script" path="res://scripts/inventory_client.gd" id="15_inventory"]
|
||||
[ext_resource type="Script" path="res://scripts/skill_progression_client.gd" id="16_skill_prog"]
|
||||
|
||||
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
|
||||
geometry_collision_mask = 1
|
||||
|
|
@ -1097,6 +1098,9 @@ script = ExtResource("14_item_defs")
|
|||
[node name="InventoryClient" type="Node" parent="." unique_id=2500007]
|
||||
script = ExtResource("15_inventory")
|
||||
|
||||
[node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008]
|
||||
script = ExtResource("16_skill_prog")
|
||||
|
||||
[node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001]
|
||||
|
||||
[node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002]
|
||||
|
|
@ -1156,11 +1160,25 @@ theme_override_font_sizes/font_size = 15
|
|||
autowrap_mode = 3
|
||||
text = "Cast: —"
|
||||
|
||||
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008]
|
||||
offset_left = 8.0
|
||||
offset_top = 376.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 402.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.88, 0.95, 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 = "Gather: —"
|
||||
|
||||
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006]
|
||||
offset_left = 8.0
|
||||
offset_top = 384.0
|
||||
offset_top = 408.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 460.0
|
||||
offset_bottom = 484.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1)
|
||||
|
|
@ -1172,9 +1190,9 @@ text = "Cooldowns: —"
|
|||
|
||||
[node name="InventoryLabel" type="Label" parent="UICanvas" unique_id=9000007]
|
||||
offset_left = 8.0
|
||||
offset_top = 472.0
|
||||
offset_top = 496.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 620.0
|
||||
offset_bottom = 644.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 1)
|
||||
|
|
@ -1184,3 +1202,18 @@ theme_override_font_sizes/font_size = 14
|
|||
autowrap_mode = 3
|
||||
text = "Inventory: (refresh: I)
|
||||
Loading…"
|
||||
|
||||
[node name="SkillProgressionLabel" type="Label" parent="UICanvas" unique_id=9000009]
|
||||
offset_left = 8.0
|
||||
offset_top = 656.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 720.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.82, 0.9, 1, 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 = "Skills:
|
||||
Loading…"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
extends Node
|
||||
|
||||
## NS-18 / NEO-25: POST InteractionRequest; prints server allow/deny to Output (prototype).
|
||||
## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] →
|
||||
## [code]prototype_resource_node_alpha[/code] (stable contract ids — must match server registry).
|
||||
## [kbd]E[/kbd] → [code]prototype_terminal[/code]; [kbd]R[/kbd] → nearest in-range
|
||||
## [code]resource_node[/code] ([code]main.gd[/code] +
|
||||
## [code]prototype_interactable_picker.gd[/code]).
|
||||
## Key handling is delegated from
|
||||
## [code]main.gd[/code] [method Node._unhandled_key_input] (see [method post_interact_terminal] /
|
||||
## [method post_interact_resource]) so the embedded Game dock still receives E/R reliably.
|
||||
## [method post_interact_id]) so the embedded Game dock still receives E/R reliably.
|
||||
|
||||
signal interaction_result_received(interactable_id: String, allowed: bool, reason_code: String)
|
||||
signal interaction_request_failed(interactable_id: String, reason: String)
|
||||
|
||||
const ID_TERMINAL := "prototype_terminal"
|
||||
const ID_RESOURCE_NODE := "prototype_resource_node_alpha"
|
||||
|
|
@ -44,6 +48,10 @@ func post_interact_resource() -> void:
|
|||
_post_interact(ID_RESOURCE_NODE)
|
||||
|
||||
|
||||
func post_interact_id(interactable_id: String) -> void:
|
||||
_post_interact(interactable_id)
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
|
@ -66,6 +74,7 @@ func _start_post(interactable_id: String) -> void:
|
|||
if err != OK:
|
||||
push_warning("InteractionRequestClient: POST failed to start (%s)" % err)
|
||||
_busy = false
|
||||
interaction_request_failed.emit(interactable_id, "POST failed to start (%s)" % err)
|
||||
_try_flush_pending()
|
||||
|
||||
|
||||
|
|
@ -88,11 +97,19 @@ func _on_request_completed(
|
|||
var d: Dictionary = parsed
|
||||
var allowed: bool = bool(d.get("allowed", false))
|
||||
var reason: Variant = d.get("reasonCode", null)
|
||||
var reason_str := ""
|
||||
if not allowed and reason != null:
|
||||
reason_str = str(reason)
|
||||
if allowed:
|
||||
print("Interaction [%s]: allowed=true (reasonCode omitted on success)" % id_echo)
|
||||
else:
|
||||
print("Interaction [%s]: allowed=false reasonCode=%s" % [id_echo, str(reason)])
|
||||
print("Interaction [%s]: allowed=false reasonCode=%s" % [id_echo, reason_str])
|
||||
interaction_result_received.emit(id_echo, allowed, reason_str)
|
||||
_try_flush_pending()
|
||||
return
|
||||
interaction_request_failed.emit(id_echo, "non-JSON body")
|
||||
_try_flush_pending()
|
||||
return
|
||||
print("Interaction [%s]: HTTP %s body=%s" % [id_echo, response_code, text])
|
||||
interaction_request_failed.emit(id_echo, "HTTP %s" % response_code)
|
||||
_try_flush_pending()
|
||||
|
|
|
|||
|
|
@ -39,9 +39,12 @@ const AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: float = 0.55
|
|||
const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12
|
||||
|
||||
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
|
||||
const PrototypeInteractablePicker := preload("res://scripts/prototype_interactable_picker.gd")
|
||||
const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd")
|
||||
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"
|
||||
|
||||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -76,6 +79,12 @@ var _cooldown_client: Node = null
|
|||
var _cooldown_poll_timer: Timer = null
|
||||
var _last_inventory_snapshot: Dictionary = {}
|
||||
var _inventory_error: String = ""
|
||||
var _interactables_catalog: Array = []
|
||||
var _last_progression_snapshot: Dictionary = {}
|
||||
var _progression_error: String = ""
|
||||
var _gather_pre_scrap_qty: int = 0
|
||||
var _gather_pending_interactable_id: String = ""
|
||||
var _gather_awaiting_inventory_finalize: bool = false
|
||||
|
||||
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
|
||||
@onready var _world: Node3D = $World
|
||||
|
|
@ -91,8 +100,11 @@ var _inventory_error: String = ""
|
|||
@onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel
|
||||
@onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
|
||||
@onready var _inventory_label: Label = $UICanvas/InventoryLabel
|
||||
@onready var _gather_feedback_label: Label = $UICanvas/GatherFeedbackLabel
|
||||
@onready var _skill_progression_label: Label = $UICanvas/SkillProgressionLabel
|
||||
@onready var _inventory_client: Node = $InventoryClient
|
||||
@onready var _item_defs_client: Node = $ItemDefinitionsClient
|
||||
@onready var _skill_progression_client: Node = $SkillProgressionClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
@onready var _interaction_client: Node = $InteractionRequestClient
|
||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||
|
|
@ -135,6 +147,8 @@ func _ready() -> void:
|
|||
_target_client.call("request_sync_from_server")
|
||||
_setup_hotbar_loadout_sync()
|
||||
_setup_inventory_sync()
|
||||
_setup_skill_progression_sync()
|
||||
_setup_gather_interact_feedback()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
|
|
@ -354,12 +368,16 @@ func _on_inventory_received(snapshot: Dictionary) -> void:
|
|||
_inventory_error = ""
|
||||
_last_inventory_snapshot = snapshot.duplicate(true)
|
||||
_render_inventory_label()
|
||||
_finalize_pending_gather_feedback()
|
||||
|
||||
|
||||
func _on_inventory_sync_failed(reason: String) -> void:
|
||||
_inventory_error = reason
|
||||
_last_inventory_snapshot = {}
|
||||
_render_inventory_label()
|
||||
if _gather_awaiting_inventory_finalize:
|
||||
_clear_gather_session()
|
||||
_render_gather_feedback_label("Gather: inventory refresh failed")
|
||||
|
||||
|
||||
func _render_inventory_label() -> void:
|
||||
|
|
@ -432,6 +450,162 @@ func _request_inventory_refresh() -> void:
|
|||
_inventory_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_skill_progression_sync() -> void:
|
||||
# NEO-73: salvage XP/level HUD row; boot hydrate + refresh after gather.
|
||||
_apply_authority_http_config_to_client(_skill_progression_client)
|
||||
if _skill_progression_client.has_signal("progression_received"):
|
||||
_skill_progression_client.connect(
|
||||
"progression_received", Callable(self, "_on_progression_received")
|
||||
)
|
||||
if _skill_progression_client.has_signal("progression_sync_failed"):
|
||||
_skill_progression_client.connect(
|
||||
"progression_sync_failed", Callable(self, "_on_progression_sync_failed")
|
||||
)
|
||||
_render_skill_progression_label()
|
||||
if _skill_progression_client.has_method("request_sync_from_server"):
|
||||
_skill_progression_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_gather_interact_feedback() -> void:
|
||||
if not is_instance_valid(_interaction_client):
|
||||
return
|
||||
if _interaction_client.has_signal("interaction_result_received"):
|
||||
_interaction_client.connect(
|
||||
"interaction_result_received", Callable(self, "_on_interaction_result_for_gather")
|
||||
)
|
||||
if _interaction_client.has_signal("interaction_request_failed"):
|
||||
_interaction_client.connect(
|
||||
"interaction_request_failed",
|
||||
Callable(self, "_on_interaction_request_failed_for_gather")
|
||||
)
|
||||
_render_gather_feedback_label()
|
||||
|
||||
|
||||
func _on_progression_received(snapshot: Dictionary) -> void:
|
||||
_progression_error = ""
|
||||
_last_progression_snapshot = snapshot.duplicate(true)
|
||||
_render_skill_progression_label()
|
||||
|
||||
|
||||
func _on_progression_sync_failed(reason: String) -> void:
|
||||
_progression_error = reason
|
||||
_last_progression_snapshot = {}
|
||||
_render_skill_progression_label()
|
||||
|
||||
|
||||
func _render_skill_progression_label() -> void:
|
||||
if not is_instance_valid(_skill_progression_label):
|
||||
return
|
||||
var header := "Skills:"
|
||||
if not _progression_error.is_empty():
|
||||
_skill_progression_label.text = "%s\nerror — %s" % [header, _progression_error]
|
||||
return
|
||||
if _last_progression_snapshot.is_empty():
|
||||
_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
|
||||
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]
|
||||
|
||||
|
||||
func _skill_row(skill_id: String) -> Dictionary:
|
||||
if not is_instance_valid(_skill_progression_client):
|
||||
return {}
|
||||
if not _skill_progression_client.has_method("skill_row"):
|
||||
return {}
|
||||
var row: Variant = _skill_progression_client.call(
|
||||
"skill_row", skill_id, _last_progression_snapshot
|
||||
)
|
||||
return row as Dictionary
|
||||
|
||||
|
||||
func _render_gather_feedback_label(text: String = "Gather: —") -> void:
|
||||
if is_instance_valid(_gather_feedback_label):
|
||||
_gather_feedback_label.text = text
|
||||
|
||||
|
||||
func _clear_gather_session() -> void:
|
||||
_gather_pending_interactable_id = ""
|
||||
_gather_awaiting_inventory_finalize = false
|
||||
|
||||
|
||||
func _on_interaction_result_for_gather(
|
||||
interactable_id: String, allowed: bool, reason_code: String
|
||||
) -> void:
|
||||
if _gather_pending_interactable_id.is_empty():
|
||||
return
|
||||
if interactable_id != _gather_pending_interactable_id:
|
||||
if _gather_awaiting_inventory_finalize:
|
||||
return
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not PrototypeInteractablePicker.is_resource_node_id(_interactables_catalog, interactable_id):
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not allowed:
|
||||
_clear_gather_session()
|
||||
var rc := reason_code.strip_edges()
|
||||
if rc.is_empty():
|
||||
_render_gather_feedback_label("Gather: denied (no reasonCode)")
|
||||
else:
|
||||
_render_gather_feedback_label("Gather: denied — %s" % rc)
|
||||
return
|
||||
_gather_awaiting_inventory_finalize = true
|
||||
_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")
|
||||
|
||||
|
||||
func _on_interaction_request_failed_for_gather(interactable_id: String, _reason: String) -> void:
|
||||
if _gather_pending_interactable_id.is_empty():
|
||||
return
|
||||
if interactable_id != _gather_pending_interactable_id:
|
||||
if not _gather_awaiting_inventory_finalize:
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not _gather_awaiting_inventory_finalize:
|
||||
_clear_gather_session()
|
||||
|
||||
|
||||
func _total_bag_qty(item_id: String, snapshot: Dictionary = {}) -> int:
|
||||
var source: Dictionary = snapshot
|
||||
if source.is_empty():
|
||||
source = _last_inventory_snapshot
|
||||
var bag: Variant = source.get("bagSlots", null)
|
||||
if bag == null or not bag is Array:
|
||||
return 0
|
||||
var total: int = 0
|
||||
for slot_variant in bag as Array:
|
||||
if not slot_variant is Dictionary:
|
||||
continue
|
||||
var slot: Dictionary = slot_variant
|
||||
if str(slot.get("itemId", "")) != item_id:
|
||||
continue
|
||||
total += int(slot.get("quantity", 0))
|
||||
return total
|
||||
|
||||
|
||||
func _finalize_pending_gather_feedback() -> void:
|
||||
if not _gather_awaiting_inventory_finalize:
|
||||
return
|
||||
var pre_qty: int = _gather_pre_scrap_qty
|
||||
_clear_gather_session()
|
||||
var new_qty: int = _total_bag_qty(SCRAP_METAL_BULK_ID)
|
||||
var delta: int = new_qty - pre_qty
|
||||
if delta <= 0:
|
||||
_render_gather_feedback_label("Gather: ok (no scrap delta)")
|
||||
return
|
||||
var label: String = _inventory_item_label(SCRAP_METAL_BULK_ID)
|
||||
_render_gather_feedback_label("Gather: +%d %s" % [delta, label])
|
||||
|
||||
|
||||
func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void:
|
||||
if (
|
||||
is_instance_valid(_cooldown_client)
|
||||
|
|
@ -579,7 +753,7 @@ func _try_interact_key_input(event: InputEvent) -> bool:
|
|||
_forward_interact_post("post_interact_terminal")
|
||||
return true
|
||||
if event.is_action_pressed("interact_secondary"):
|
||||
_forward_interact_post("post_interact_resource")
|
||||
_forward_interact_resource_nearest()
|
||||
return true
|
||||
if event is InputEventKey:
|
||||
var k := event as InputEventKey
|
||||
|
|
@ -588,7 +762,7 @@ func _try_interact_key_input(event: InputEvent) -> bool:
|
|||
_forward_interact_post("post_interact_terminal")
|
||||
return true
|
||||
if k.physical_keycode == KEY_R or k.keycode == KEY_R:
|
||||
_forward_interact_post("post_interact_resource")
|
||||
_forward_interact_resource_nearest()
|
||||
return true
|
||||
return false
|
||||
|
||||
|
|
@ -664,6 +838,19 @@ func _forward_interact_post(method: String) -> void:
|
|||
_interaction_client.call(method)
|
||||
|
||||
|
||||
func _forward_interact_resource_nearest() -> void:
|
||||
if not is_instance_valid(_interaction_client) or not is_instance_valid(_player):
|
||||
return
|
||||
var node_id: String = PrototypeInteractablePicker.nearest_resource_node_id(
|
||||
_interactables_catalog, _player.global_position
|
||||
)
|
||||
_gather_pre_scrap_qty = _total_bag_qty(SCRAP_METAL_BULK_ID)
|
||||
_gather_pending_interactable_id = node_id
|
||||
_gather_awaiting_inventory_finalize = false
|
||||
if _interaction_client.has_method("post_interact_id"):
|
||||
_interaction_client.call("post_interact_id", node_id)
|
||||
|
||||
|
||||
func _dev_toggle_obstacle_smoke_deferred() -> void:
|
||||
var obstacle := _dev_obstacle_smoke
|
||||
if obstacle == null or not is_instance_valid(obstacle):
|
||||
|
|
@ -717,6 +904,7 @@ func _dev_collision_shapes_set_disabled(root: Node, disabled: bool) -> void:
|
|||
|
||||
|
||||
func _on_interactables_catalog_ready(descriptors: Array) -> void:
|
||||
_interactables_catalog = descriptors.duplicate(true)
|
||||
var groups: Variant = load("res://scripts/interactable_world_builder.gd").call(
|
||||
"build_from_catalog", descriptors, _interactables_root, _radius_preview
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
extends Object
|
||||
|
||||
## NEO-73: client-side nearest [code]resource_node[/code] picker for gather interact ([kbd]R[/kbd]).
|
||||
## Horizontal X/Z distance + inclusive radius — same rule as
|
||||
## [code]interaction_radius_indicators.gd[/code].
|
||||
|
||||
const KIND_RESOURCE_NODE := "resource_node"
|
||||
const FALLBACK_RESOURCE_NODE_ID := "prototype_resource_node_alpha"
|
||||
|
||||
const KNOWN_RESOURCE_NODE_IDS: Array[String] = [
|
||||
"prototype_resource_node_alpha",
|
||||
"prototype_subsurface_vein_beta",
|
||||
"prototype_bio_mat_gamma",
|
||||
"prototype_urban_bulk_delta",
|
||||
]
|
||||
|
||||
|
||||
static func nearest_resource_node_id(descriptors: Array, world: Vector3) -> String:
|
||||
var best_id := ""
|
||||
var best_dist_sq: float = INF
|
||||
for descriptor_variant in descriptors:
|
||||
if not descriptor_variant is Dictionary:
|
||||
continue
|
||||
var descriptor: Dictionary = descriptor_variant
|
||||
if str(descriptor.get("kind", "")) != KIND_RESOURCE_NODE:
|
||||
continue
|
||||
var interactable_id: String = str(descriptor.get("interactableId", ""))
|
||||
if interactable_id.is_empty():
|
||||
continue
|
||||
var dist_sq: float = _horizontal_distance_sq_to_descriptor(descriptor, world)
|
||||
var radius: float = float(descriptor.get("interactionRadius", 0.0))
|
||||
if dist_sq > radius * radius:
|
||||
continue
|
||||
if (
|
||||
best_id.is_empty()
|
||||
or dist_sq < best_dist_sq - 1e-9
|
||||
or (absf(dist_sq - best_dist_sq) <= 1e-9 and interactable_id < best_id)
|
||||
):
|
||||
best_dist_sq = dist_sq
|
||||
best_id = interactable_id
|
||||
if best_id.is_empty():
|
||||
return FALLBACK_RESOURCE_NODE_ID
|
||||
return best_id
|
||||
|
||||
|
||||
static func descriptor_kind(descriptors: Array, interactable_id: String) -> String:
|
||||
for descriptor_variant in descriptors:
|
||||
if not descriptor_variant is Dictionary:
|
||||
continue
|
||||
var descriptor: Dictionary = descriptor_variant
|
||||
if str(descriptor.get("interactableId", "")) == interactable_id:
|
||||
return str(descriptor.get("kind", ""))
|
||||
return ""
|
||||
|
||||
|
||||
static func is_resource_node_id(descriptors: Array, interactable_id: String) -> bool:
|
||||
if descriptor_kind(descriptors, interactable_id) == KIND_RESOURCE_NODE:
|
||||
return true
|
||||
return KNOWN_RESOURCE_NODE_IDS.has(interactable_id)
|
||||
|
||||
|
||||
static func _horizontal_distance_sq_to_descriptor(descriptor: Dictionary, world: Vector3) -> float:
|
||||
var anchor_variant: Variant = descriptor.get("anchor", null)
|
||||
if not anchor_variant is Dictionary:
|
||||
return INF
|
||||
var anchor: Dictionary = anchor_variant
|
||||
var dx: float = world.x - float(anchor.get("x", 0.0))
|
||||
var dz: float = world.z - float(anchor.get("z", 0.0))
|
||||
return dx * dx + dz * dz
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo73ixpicker01
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
extends Node
|
||||
|
||||
## NEO-73: prototype HTTP client for [code]GET /game/players/{id}/skill-progression[/code] (NEO-37).
|
||||
|
||||
signal progression_received(snapshot: Dictionary)
|
||||
signal progression_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/players/%s/skill-progression" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("SkillProgressionClient: %s" % reason)
|
||||
_busy = false
|
||||
progression_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func skill_row(skill_id: String, snapshot: Dictionary = {}) -> Dictionary:
|
||||
var skills: Variant = snapshot.get("skills", null)
|
||||
if skills == null or not skills is Array:
|
||||
return {}
|
||||
for row_variant in skills as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("id", "")) == skill_id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_progression_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 skills: Variant = root.get("skills", null)
|
||||
if skills == null or not skills is Array:
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
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("SkillProgressionClient: %s" % reason)
|
||||
progression_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("SkillProgressionClient: %s" % reason404)
|
||||
progression_sync_failed.emit(reason404)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("SkillProgressionClient: %s" % reason_code)
|
||||
progression_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_progression_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("SkillProgressionClient: %s" % reason_json)
|
||||
progression_sync_failed.emit(reason_json)
|
||||
return
|
||||
progression_received.emit(snapshot as Dictionary)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo73skillprog01
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-73: allowed resource-node interact triggers inventory + skill refresh.
|
||||
|
||||
const IxClient := preload("res://scripts/interaction_request_client.gd")
|
||||
const InventoryClient := preload("res://scripts/inventory_client.gd")
|
||||
const SkillProgressionClient := preload("res://scripts/skill_progression_client.gd")
|
||||
const Picker := preload("res://scripts/prototype_interactable_picker.gd")
|
||||
|
||||
|
||||
class MockInteractTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_body: String = ""
|
||||
var body_json: String = '{"schemaVersion":1,"allowed":true}'
|
||||
var response_code: int = 200
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
request_data: String = ""
|
||||
) -> Error:
|
||||
last_body = request_data
|
||||
var body_bytes: PackedByteArray = body_json.to_utf8_buffer()
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS, response_code, PackedStringArray(), body_bytes
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class SpySyncClient:
|
||||
extends Node
|
||||
var sync_calls: int = 0
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
sync_calls += 1
|
||||
|
||||
|
||||
class GatherRefreshHarness:
|
||||
extends Node
|
||||
var inventory_sync_calls: int = 0
|
||||
var skill_sync_calls: int = 0
|
||||
var pending_gather_interactable_id: String = ""
|
||||
var awaiting_inventory_finalize: bool = false
|
||||
var _inventory: SpySyncClient
|
||||
var _skill: SpySyncClient
|
||||
var _ix: Node
|
||||
|
||||
func setup(parent: Node, ix_transport: Node) -> void:
|
||||
_inventory = SpySyncClient.new()
|
||||
_skill = SpySyncClient.new()
|
||||
_ix = IxClient.new()
|
||||
_ix.set("injected_http", ix_transport)
|
||||
parent.add_child(_ix)
|
||||
parent.add_child(_inventory)
|
||||
parent.add_child(_skill)
|
||||
_ix.connect("interaction_result_received", Callable(self, "_on_interaction_result"))
|
||||
|
||||
func queue_resource_gather(interactable_id: String) -> void:
|
||||
pending_gather_interactable_id = interactable_id
|
||||
awaiting_inventory_finalize = false
|
||||
|
||||
func _clear_gather_session() -> void:
|
||||
pending_gather_interactable_id = ""
|
||||
awaiting_inventory_finalize = false
|
||||
|
||||
func _on_interaction_result(
|
||||
interactable_id: String, allowed: bool, _reason_code: String
|
||||
) -> void:
|
||||
if pending_gather_interactable_id.is_empty():
|
||||
return
|
||||
if interactable_id != pending_gather_interactable_id:
|
||||
if awaiting_inventory_finalize:
|
||||
return
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not Picker.is_resource_node_id([], interactable_id):
|
||||
_clear_gather_session()
|
||||
return
|
||||
if not allowed:
|
||||
_clear_gather_session()
|
||||
return
|
||||
awaiting_inventory_finalize = true
|
||||
_inventory.request_sync_from_server()
|
||||
_skill.request_sync_from_server()
|
||||
|
||||
func post_resource_alpha() -> void:
|
||||
queue_resource_gather("prototype_resource_node_alpha")
|
||||
_ix.call("post_interact_id", "prototype_resource_node_alpha")
|
||||
|
||||
func emit_terminal_allowed() -> void:
|
||||
_ix.set("_current_interactable_id", "prototype_terminal")
|
||||
var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer()
|
||||
_ix.call("_on_request_completed", 0, 200, PackedStringArray(), body_bytes)
|
||||
|
||||
|
||||
func test_allowed_resource_interact_triggers_both_refreshes() -> void:
|
||||
# Arrange
|
||||
var transport := MockInteractTransport.new()
|
||||
var harness := GatherRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._ix)
|
||||
# Act
|
||||
harness.post_resource_alpha()
|
||||
# Assert
|
||||
assert_signal(harness._ix).is_emitted("interaction_result_received")
|
||||
assert_that(harness._inventory.sync_calls).is_equal(1)
|
||||
assert_that(harness._skill.sync_calls).is_equal(1)
|
||||
|
||||
|
||||
func test_denied_resource_interact_skips_refreshes() -> void:
|
||||
# Arrange
|
||||
var transport := MockInteractTransport.new()
|
||||
transport.body_json = '{"schemaVersion":1,"allowed":false,"reasonCode":"node_depleted"}'
|
||||
var harness := GatherRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
# Act
|
||||
harness.post_resource_alpha()
|
||||
# Assert
|
||||
assert_that(harness._inventory.sync_calls).is_equal(0)
|
||||
assert_that(harness._skill.sync_calls).is_equal(0)
|
||||
|
||||
|
||||
func test_terminal_result_clears_orphaned_gather_pending() -> void:
|
||||
# Arrange
|
||||
var transport := MockInteractTransport.new()
|
||||
var harness := GatherRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
harness.queue_resource_gather("prototype_resource_node_alpha")
|
||||
# Act
|
||||
harness.emit_terminal_allowed()
|
||||
# Assert
|
||||
assert_that(harness.pending_gather_interactable_id).is_empty()
|
||||
assert_that(harness.awaiting_inventory_finalize).is_false()
|
||||
assert_that(harness._inventory.sync_calls).is_equal(0)
|
||||
assert_that(harness._skill.sync_calls).is_equal(0)
|
||||
|
||||
|
||||
func test_terminal_after_gather_success_keeps_awaiting_inventory() -> void:
|
||||
# Arrange
|
||||
var transport := MockInteractTransport.new()
|
||||
var harness := GatherRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
# Act
|
||||
harness.post_resource_alpha()
|
||||
harness.emit_terminal_allowed()
|
||||
# Assert
|
||||
assert_that(harness.awaiting_inventory_finalize).is_true()
|
||||
assert_that(harness.pending_gather_interactable_id).is_equal("prototype_resource_node_alpha")
|
||||
assert_that(harness._inventory.sync_calls).is_equal(1)
|
||||
assert_that(harness._skill.sync_calls).is_equal(1)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cpjyg7kctbh2x
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-25: `InteractionRequestClient` POST bodies for E vs R contract ids.
|
||||
## NEO-25 / NEO-73: `InteractionRequestClient` POST bodies, result/failure signals.
|
||||
|
||||
const IxClient := preload("res://scripts/interaction_request_client.gd")
|
||||
|
||||
|
|
@ -12,19 +12,23 @@ class MockHttpTransport:
|
|||
)
|
||||
var last_url: String = ""
|
||||
var last_body: String = ""
|
||||
var last_method: HTTPClient.Method = HTTPClient.METHOD_GET
|
||||
var last_method: HTTPClient.Method = HTTPClient.Method.METHOD_GET
|
||||
var body_json: String = '{"schemaVersion":1,"allowed":true}'
|
||||
var response_code: int = 200
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
method: HTTPClient.Method = HTTPClient.Method.METHOD_GET,
|
||||
request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
last_body = request_data
|
||||
last_method = method
|
||||
var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer()
|
||||
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
|
||||
var body_bytes: PackedByteArray = body_json.to_utf8_buffer()
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS, response_code, PackedStringArray(), body_bytes
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
|
|
@ -106,6 +110,47 @@ func test_post_interact_resource_public_entrypoint() -> void:
|
|||
assert_that(transport.last_body).contains("prototype_resource_node_alpha")
|
||||
|
||||
|
||||
func test_interaction_result_emits_allow_signal() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
var ix := _make_ix(transport)
|
||||
monitor_signals(ix)
|
||||
# Act
|
||||
ix.call("post_interact_id", "prototype_resource_node_alpha")
|
||||
# Assert
|
||||
assert_signal(ix).is_emitted(
|
||||
"interaction_result_received", "prototype_resource_node_alpha", true, ""
|
||||
)
|
||||
|
||||
|
||||
func test_interaction_result_emits_deny_signal() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = '{"schemaVersion":1,"allowed":false,"reasonCode":"node_depleted"}'
|
||||
var ix := _make_ix(transport)
|
||||
monitor_signals(ix)
|
||||
# Act
|
||||
ix.call("post_interact_id", "prototype_urban_bulk_delta")
|
||||
# Assert
|
||||
assert_signal(ix).is_emitted(
|
||||
"interaction_result_received", "prototype_urban_bulk_delta", false, "node_depleted"
|
||||
)
|
||||
|
||||
|
||||
func test_interaction_request_failed_emits_on_http_error() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 500
|
||||
var ix := _make_ix(transport)
|
||||
monitor_signals(ix)
|
||||
# Act
|
||||
ix.call("post_interact_id", "prototype_resource_node_alpha")
|
||||
# Assert
|
||||
assert_signal(ix).is_emitted(
|
||||
"interaction_request_failed", "prototype_resource_node_alpha", "HTTP 500"
|
||||
)
|
||||
|
||||
|
||||
func test_last_wins_second_press_before_first_completes() -> void:
|
||||
# Arrange
|
||||
var transport := HoldFirstThenAutoTransport.new()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-73: nearest in-range resource node picker for gather interact.
|
||||
|
||||
const Picker := preload("res://scripts/prototype_interactable_picker.gd")
|
||||
|
||||
|
||||
static func _descriptor(id: String, kind: String, x: float, z: float, radius: float) -> Dictionary:
|
||||
return {
|
||||
"interactableId": id,
|
||||
"kind": kind,
|
||||
"anchor": {"x": x, "y": 0.5, "z": z},
|
||||
"interactionRadius": radius,
|
||||
}
|
||||
|
||||
|
||||
func test_nearest_resource_node_picks_closest_in_range() -> void:
|
||||
# Arrange
|
||||
var descriptors: Array = [
|
||||
_descriptor("prototype_terminal", "terminal", 0.0, 0.0, 3.0),
|
||||
_descriptor("prototype_resource_node_alpha", "resource_node", 12.0, -6.0, 3.0),
|
||||
_descriptor("prototype_subsurface_vein_beta", "resource_node", 18.0, -6.0, 3.0),
|
||||
]
|
||||
var world := Vector3(17.0, 0.9, -6.0)
|
||||
# Act
|
||||
var picked: String = Picker.nearest_resource_node_id(descriptors, world)
|
||||
# Assert
|
||||
assert_that(picked).is_equal("prototype_subsurface_vein_beta")
|
||||
|
||||
|
||||
func test_nearest_resource_node_falls_back_to_alpha_when_out_of_range() -> void:
|
||||
# Arrange
|
||||
var descriptors: Array = [
|
||||
_descriptor("prototype_resource_node_alpha", "resource_node", 12.0, -6.0, 3.0),
|
||||
]
|
||||
var world := Vector3(-5.0, 0.9, -5.0)
|
||||
# Act
|
||||
var picked: String = Picker.nearest_resource_node_id(descriptors, world)
|
||||
# Assert
|
||||
assert_that(picked).is_equal("prototype_resource_node_alpha")
|
||||
|
||||
|
||||
func test_is_resource_node_id_uses_known_ids_when_catalog_empty() -> void:
|
||||
# Arrange
|
||||
# Act
|
||||
var is_node: bool = Picker.is_resource_node_id([], "prototype_bio_mat_gamma")
|
||||
# Assert
|
||||
assert_that(is_node).is_true()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cf8xqlajsa2pv
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-73: `SkillProgressionClient` GET parse + failure signals.
|
||||
|
||||
const SkillProgressionClient := preload("res://scripts/skill_progression_client.gd")
|
||||
|
||||
var _progression_capture: Dictionary = {}
|
||||
|
||||
|
||||
func _capture_progression(snapshot: Dictionary) -> void:
|
||||
_progression_capture = snapshot
|
||||
|
||||
|
||||
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 _trio_progression_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","skills":['
|
||||
+ '{"id":"salvage","xp":10,"level":1},'
|
||||
+ '{"id":"refine","xp":0,"level":1},'
|
||||
+ '{"id":"intrusion","xp":0,"level":1}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = SkillProgressionClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_progression_json_reads_salvage_row() -> void:
|
||||
# Arrange
|
||||
var json := _trio_progression_json()
|
||||
# Act
|
||||
var snapshot: Variant = SkillProgressionClient.parse_progression_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = SkillProgressionClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("skill_row", "salvage", snapshot as Dictionary)
|
||||
assert_that(int(row.get("xp", -1))).is_equal(10)
|
||||
assert_that(int(row.get("level", -1))).is_equal(1)
|
||||
|
||||
|
||||
func test_request_sync_emits_progression_received() -> void:
|
||||
# Arrange
|
||||
_progression_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _trio_progression_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("progression_received", Callable(self, "_capture_progression"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("progression_received")
|
||||
assert_that(_progression_capture.has("skills")).is_true()
|
||||
assert_that(transport.last_url).contains("/skill-progression")
|
||||
|
||||
|
||||
func test_http_404_emits_progression_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bkn5ajmbq4rvg
|
||||
|
|
@ -102,7 +102,7 @@ Epic 3 **Slice 2** — [gather nodes and outputs](../epics/epic_03_crafting_econ
|
|||
|
||||
**Resource node instance depletion store (NEO-61):** **`IResourceNodeInstanceStore`** + **`ResourceNodeInstanceOperations`** — lazy-init remaining gathers from catalog **`maxGathers`**, atomic decrement, **`node_depleted`** / **`unknown_node`** reason codes; Postgres **`V006`** or in-memory fallback. Plan: [NEO-61 implementation plan](../../plans/NEO-61-implementation-plan.md).
|
||||
|
||||
**Linear backlog (decomposed):** [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md) — **E3M1-01** [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) through **E3M1-08** [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64). **Client (Slice 5):** gather feedback — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-02** [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73).
|
||||
**Linear backlog (decomposed):** [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md) — **E3M1-01** [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) through **E3M1-08** [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64). **Client (Slice 5):** gather feedback **landed** — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-02** [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73): **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73 implementation plan](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section.
|
||||
|
||||
## Source anchors
|
||||
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ 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. **Slice 5 client (planned):** gather feedback HUD [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-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/` |
|
||||
| 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):** gather feedback [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73), 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.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) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
# NEO-73 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-73 |
|
||||
| Title | E3.M1: Client gather feedback on interact (E3S5-02) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-73/e3m1-client-gather-feedback-on-interact-e3s5-02 |
|
||||
| Plan | `docs/plans/NEO-73-implementation-plan.md` |
|
||||
| Branch | `NEO-73-e3m1-client-gather-feedback-on-interact` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Server running with default dev player (`dev-local-1`); gather via interact landed (NEO-63); inventory HUD landed (NEO-72).
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`SkillProgressionLabel`** shows **`salvage: level …, xp …`** after boot (often **`level 1, xp 0`** on fresh player).
|
||||
3. **WASD** to **`prototype_resource_node_alpha`** anchor **(12, −6)** until the resource glow pair brightens. Press **R** (do **not** press **I** first).
|
||||
4. **`GatherFeedbackLabel`** shows **`Gather: +1 Scrap Metal (Bulk)`** (or agreed display name). **`InventoryLabel`** updates scrap quantity without manual refresh. **`SkillProgressionLabel`** **`salvage`** xp increases by **10**.
|
||||
5. Walk to **`prototype_subsurface_vein_beta`** **(18, −6)**; press **R**. Feedback shows **`+2`** scrap delta; inventory and salvage xp update again.
|
||||
6. Repeat at **`prototype_bio_mat_gamma`** **(12, 0)** (**+3**) and **`prototype_urban_bulk_delta`** **(18, 0)** (**+5**) if nodes still have capacity.
|
||||
7. Deplete **`prototype_urban_bulk_delta`**: press **R** ten times while in range, then once more. **`GatherFeedbackLabel`** shows **`Gather: denied — node_depleted`** (inventory unchanged on last press — verify with **I** if needed).
|
||||
8. Press **E** at terminal: **`GatherFeedbackLabel`** stays on last gather message (terminal does not overwrite gather line).
|
||||
|
||||
## Notes
|
||||
|
||||
- **R** posts the **nearest in-range** `resource_node` from the interactables catalog; walk to each anchor before pressing **R**.
|
||||
- Output still prints **`Interaction [<id>]: …`** lines for dev debugging.
|
||||
|
|
@ -101,9 +101,9 @@ Working backlog for **Epic 3 — Slice 5** ([client economy integration](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] In-range interact on fresh node updates inventory HUD without manual refresh.
|
||||
- [ ] Depleted node shows **`node_depleted`** (or agreed copy) on HUD.
|
||||
- [ ] **Salvage** skill level or XP visible and changes after successful gather (Epic 2 Slice 3 visibility).
|
||||
- [x] In-range interact on fresh node updates inventory HUD without manual refresh.
|
||||
- [x] Depleted node shows **`node_depleted`** (or agreed copy) on HUD.
|
||||
- [x] **Salvage** skill level or XP visible and changes after successful gather (Epic 2 Slice 3 visibility).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
# NEO-73 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-73 |
|
||||
| **Title** | E3.M1: Client gather feedback on interact (E3S5-02) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-73/e3m1-client-gather-feedback-on-interact-e3s5-02 |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 **Slice 5** (client 2 of 4) · backlog **E3S5-02** |
|
||||
| **Branch** | `NEO-73-e3m1-client-gather-feedback-on-interact` |
|
||||
| **Server deps** | [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) — gather via **`POST …/interact`** (**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) — **`GET …/skill-progression`** (**Done**) |
|
||||
| **Pattern** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28) / [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — 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 loop; [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) reuses **`SkillProgressionClient`** for **refine** row |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Four-node R wiring** | How should **R** pick the resource node id? | **Nearest in-range `resource_node`** from cached interactables catalog — no new keys; walk to each anchor, press **R**. | **User:** nearest in-range from catalog. |
|
||||
| **Gather success copy** | What does **`GatherFeedbackLabel`** show? | **Inventory delta after refresh** — `+N {displayName}` for **`scrap_metal_bulk`** (compare pre/post snapshot qty). | **User:** inventory delta. |
|
||||
| **Salvage XP HUD** | Where does salvage xp/level live? | **`SkillProgressionLabel`** — persistent salvage row; boot hydrate + refresh after successful gather. | **User:** dedicated skill progression label. |
|
||||
|
||||
No other blocking decisions — **`node_depleted`** deny copy follows **`CastFeedbackLabel`** pattern (`Gather: denied — node_depleted`); terminal **E** interact unchanged.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** After successful **`resource_node`** interact, the player **sees** inventory change and **salvage** XP movement on HUD — not Output-panel-only proof.
|
||||
|
||||
**In scope (from Linear + [E3S5-02](E3S5-client-prototype-backlog.md#e3s5-02--client-gather-feedback-on-interact-e3m1)):**
|
||||
|
||||
- **`skill_progression_client.gd`:** GET snapshot; parse v1 envelope; emit **`progression_received`** / **`progression_sync_failed`**; public **`request_sync_from_server()`**; **`skill_row(skill_id)`** helper for **`salvage`**.
|
||||
- **`SkillProgressionLabel`** under **`UICanvas`:** shows **`salvage: level N, xp M`** (and optional **`refine`** stub line omitted until NEO-74).
|
||||
- **`GatherFeedbackLabel`:** success delta line + deny lines for gather interacts.
|
||||
- Extend **`interaction_request_client.gd`:** emit **`interaction_result_received(interactable_id, allowed, reason_code)`** on parsed 200 (keep Output **`print`** for dev).
|
||||
- **`main.gd` gather path:**
|
||||
- Cache interactables catalog descriptors from **`catalog_ready`**.
|
||||
- **R** → resolve **nearest in-range** descriptor with **`kind == resource_node`** (horizontal X/Z, inclusive **`<= interactionRadius`** — same math as **`interaction_radius_indicators.gd`**); fall back to **`prototype_resource_node_alpha`** when none in range (server returns **`out_of_range`**).
|
||||
- On **`interaction_result_received`** when id is a known resource node:
|
||||
- **`allowed=true`:** snapshot pre-gather **`scrap_metal_bulk`** qty → **`inventory_client.request_sync_from_server()`** + **`skill_progression_client.request_sync_from_server()`** → on both (or inventory-first then skill) compute delta → paint **`GatherFeedbackLabel`**.
|
||||
- **`allowed=false`:** paint **`GatherFeedbackLabel`** with **`Gather: denied — {reasonCode}`** (including **`node_depleted`**); no refresh.
|
||||
- Boot: chain **`skill_progression_client.request_sync_from_server()`** alongside inventory hydrate (after authority config wired).
|
||||
- GdUnit: mock interact 200 → **`main`** handler (or client signal) triggers inventory + skill refresh; skill client parse tests.
|
||||
- **`client/README.md`** gather feedback subsection.
|
||||
- **`docs/manual-qa/NEO-73.md`**.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- New gather HTTP route; node respawn visuals; gather VFX.
|
||||
- **`refine`** skill row polish (NEO-74); resource-node-defs preview client (NEO-60 optional).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] In-range interact on fresh node updates **inventory HUD** without manual **I** refresh.
|
||||
- [x] Depleted node shows **`node_depleted`** (or agreed copy) on **`GatherFeedbackLabel`**.
|
||||
- [x] **Salvage** skill level/XP visible on **`SkillProgressionLabel`** and changes after successful gather.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Clients:** `skill_progression_client.gd`; `interaction_request_client.gd` emits **`interaction_result_received`** + **`post_interact_id`**.
|
||||
- **Picker:** `prototype_interactable_picker.gd` — nearest in-range **`resource_node`** on **R** (alpha fallback).
|
||||
- **HUD:** `GatherFeedbackLabel` (delta / deny); `SkillProgressionLabel` (salvage row); inventory auto-refresh on gather success.
|
||||
- **Tests:** `skill_progression_client_test.gd`, `prototype_interactable_picker_test.gd`, `gather_feedback_refresh_test.gd`; extended `interaction_request_client_test.gd`.
|
||||
- **Docs:** `client/README.md` gather section; [`NEO-73` manual QA](../manual-qa/NEO-73.md).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`skill_progression_client.gd`**
|
||||
- Mirror **`inventory_client.gd`**: injectable HTTP, **`_busy`** guard, **`GET …/skill-progression`**.
|
||||
- **`parse_progression_json(text)`** static: require **`schemaVersion` 1**, **`skills`** array.
|
||||
- **`skill_row(skill_id: String) -> Dictionary`:** find row by **`id`**; empty dict if missing.
|
||||
- Signals: **`progression_received(snapshot)`**, **`progression_sync_failed(reason)`**.
|
||||
|
||||
2. **`interaction_request_client.gd`**
|
||||
- Add **`signal interaction_result_received(interactable_id: String, allowed: bool, reason_code: String)`**.
|
||||
- On 200 + parsed dict: emit signal; **`reason_code`** empty string when allowed.
|
||||
- Add **`post_interact_id(interactable_id: String)`** public entry (used by **`main`** for nearest-node resolution); keep **`post_interact_resource()`** as thin wrapper or deprecate in favor of **`main`**-resolved id only.
|
||||
|
||||
3. **Nearest resource node helper (`main.gd` or small `prototype_interactable_picker.gd`)**
|
||||
- Input: cached descriptor **`Array`**, player **`Vector3`** world position.
|
||||
- Filter **`kind == "resource_node"`**; compute horizontal distance to **`anchor`**; pick minimum **`<= interactionRadius`**.
|
||||
- Tie-break: ascending **`interactableId`** (stable with server ordering).
|
||||
- Store descriptors in **`_interactables_catalog: Array`** on **`catalog_ready`**.
|
||||
|
||||
4. **Gather feedback orchestration (`main.gd`)**
|
||||
- **`_gather_pre_scrap_qty: int`** — set from **`_total_bag_qty("scrap_metal_bulk")`** immediately before POST when posting a resource node id.
|
||||
- Connect **`interaction_result_received`** → **`_on_interaction_result_for_gather`**.
|
||||
- Success path: refresh inventory + skill progression; on **`inventory_received`**, if pending gather feedback flag set, compute **`delta = new_qty - _gather_pre_scrap_qty`**, format **`Gather: +{delta} {displayName}`** on **`GatherFeedbackLabel`**; clear flag.
|
||||
- Deny path: **`Gather: denied — {reasonCode}`** on label ( **`node_depleted`**, **`inventory_full`**, **`out_of_range`**, etc.).
|
||||
- Terminal **E** interact: optional **`Interaction: …`** line or leave **`GatherFeedbackLabel`** unchanged (prefer unchanged unless we want generic interact deny — keep gather label gather-specific only).
|
||||
|
||||
5. **`SkillProgressionLabel` rendering**
|
||||
- Header **`Skills:`**; row **`salvage: level {level}, xp {xp}`** from cached snapshot.
|
||||
- Error line on **`progression_sync_failed`** (mirror inventory error UX).
|
||||
- Re-render on **`progression_received`** and after gather refresh.
|
||||
|
||||
6. **Scene layout (`main.tscn`)**
|
||||
- Add **`SkillProgressionClient`**, **`GatherFeedbackLabel`**, **`SkillProgressionLabel`** nodes.
|
||||
- **`GatherFeedbackLabel`:** below **`CastFeedbackLabel`** (~offset_top 378) or between cast and cooldown — short single-line feedback.
|
||||
- **`SkillProgressionLabel`:** below **`InventoryLabel`** (~offset_top 628) or stack under inventory block; shift layout if needed for readability.
|
||||
- **`SkillProgressionClient`:** sibling HTTP client node like **`InventoryClient`**.
|
||||
|
||||
7. **Tests**
|
||||
- **`skill_progression_client_test.gd`:** mock 200 with trio skills → **`skill_row("salvage")`** has xp/level; 404 → failure signal.
|
||||
- **`interaction_request_client_test.gd`:** extend — mock 200 deny → signal **`allowed=false`**, **`reason_code=node_depleted`**.
|
||||
- **`gather_feedback_refresh_test.gd`** (or extend **`interaction_request_client_test`**): stub **`main`** gather handler / test double that verifies inventory + skill **`request_sync_from_server`** called on allowed resource node signal (AAA).
|
||||
|
||||
8. **Docs on land**
|
||||
- Update [E3_M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E3.M1 row when implementation completes.
|
||||
- **`E3S5-client-prototype-backlog.md`:** check E3S5-02 boxes.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-73-implementation-plan.md` | This plan. |
|
||||
| `client/scripts/skill_progression_client.gd` | GET player skill progression; **`progression_received`** / **`progression_sync_failed`**; **`skill_row`**. |
|
||||
| `client/scripts/skill_progression_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/test/skill_progression_client_test.gd` | GdUnit: parse v1 skills; salvage row lookup; HTTP error path. AAA layout. |
|
||||
| `client/test/gather_feedback_refresh_test.gd` | GdUnit: interact allow signal → inventory + skill refresh invoked (mock/spy). AAA layout. |
|
||||
| `docs/manual-qa/NEO-73.md` | Fresh node gather, four-node **R** nearest pick, depletion deny, salvage XP delta, no manual **I**. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/interaction_request_client.gd` | Emit **`interaction_result_received`**; add **`post_interact_id`**. |
|
||||
| `client/scripts/main.gd` | Catalog cache, nearest-node **R** routing, gather feedback orchestration, skill/inventory boot + signal wiring, HUD render helpers. |
|
||||
| `client/scenes/main.tscn` | Add **`SkillProgressionClient`**, **`GatherFeedbackLabel`**, **`SkillProgressionLabel`**. |
|
||||
| `client/test/interaction_request_client_test.gd` | Assert new signal on allow/deny mock responses. |
|
||||
| `client/README.md` | Gather feedback: **R** nearest-node rule, HUD labels, boot hydrate, server deps. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `client/test/skill_progression_client_test.gd` | Mock 200 with **`salvage`** row **`xp`/`level`** → **`progression_received`** + **`skill_row`**; 404 → **`progression_sync_failed`**. AAA layout. |
|
||||
| `client/test/interaction_request_client_test.gd` | Mock deny 200 → **`interaction_result_received(false, "node_depleted")`**; allow → **`allowed=true`**. AAA layout. |
|
||||
| `client/test/gather_feedback_refresh_test.gd` | Lightweight **`main.gd`** gather handler test or extracted callable: resource node allow → **`request_sync_from_server`** on inventory + skill clients (spy nodes). AAA layout. |
|
||||
|
||||
No new **C#** tests (client-only). No new Bruno (server covered by NEO-63).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Async refresh race (inventory vs skill)** | Paint gather delta when inventory arrives; refresh skill label independently on **`progression_received`** — acceptable lag ≤1 RTT. | **adopted** |
|
||||
| **Multi-item deltas** | Prototype yields only **`scrap_metal_bulk`**; sum bag qty for that id only. | **adopted** |
|
||||
| **No in-range node on R** | Fall back to alpha id; server **`out_of_range`** + HUD deny line — same as today when far from nodes. | **adopted** |
|
||||
| **Terminal E pollutes GatherFeedbackLabel** | Leave gather label unchanged on non-resource-node interacts. | **adopted** |
|
||||
| **HUD vertical space** | Offset labels below inventory; optional collapse deferred to NEO-75. | **deferred** (NEO-75) |
|
||||
| **Slice 5 module docs** | Update alignment register when NEO-73 lands, not at kickoff. | **deferred** (implementation) |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **R wiring:** nearest in-range **`resource_node`** from cached interactables catalog (user confirmed).
|
||||
- **Gather success copy:** inventory delta **`+N displayName`** after refresh (user confirmed).
|
||||
- **Skill HUD:** dedicated **`SkillProgressionLabel`** with salvage row; boot + post-gather refresh (user confirmed).
|
||||
- **Deny UX:** **`GatherFeedbackLabel`** + existing Output **`print`** (interaction client).
|
||||
- **No new gather HTTP** on client; reuse **`POST …/interact`**.
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
# Code review — NEO-73 client gather feedback on interact
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-73-e3m1-client-gather-feedback-on-interact` · commits `4682db4`–`b52428e` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
**Follow-up:** Review feedback addressed in `434226a` (interaction failure signal, docs, tests).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve** — implementation matches the adopted plan and E3S5-02 acceptance criteria; GdUnit suites pass. Follow-up cleared pending-gather flag on interact HTTP failures and updated alignment/module docs.
|
||||
|
||||
## Summary
|
||||
|
||||
The branch adds **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`**, gather/skill HUD labels, **`interaction_result_received`** on **`interaction_request_client.gd`**, and orchestration in **`main.gd`** (nearest-node **R**, inventory delta on **`GatherFeedbackLabel`**, salvage row on **`SkillProgressionLabel`**, auto-refresh after successful gather). Four new GdUnit suites extend interaction and client coverage; manual QA and plan reconciliation are present. Design follows NEO-72/NEO-28 patterns (thin HTTP clients, `_busy` guard, authority config copy, HUD error lines). **`main.gd`** grows by ~150 lines but the picker and skill client are appropriately extracted.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-73-implementation-plan.md`](../plans/NEO-73-implementation-plan.md) | **Matches** — kickoff decisions (nearest **R**, inventory delta copy, dedicated skill label) reflected; acceptance checklist and reconciliation complete. |
|
||||
| [`docs/plans/E3S5-client-prototype-backlog.md`](../plans/E3S5-client-prototype-backlog.md) · **E3S5-02** | **Matches** — acceptance criteria checked; scope/out-of-scope honored. |
|
||||
| [`docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md`](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Matches** — NEO-73 client slice **landed** note added. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1 row notes NEO-73 landed; NEO-73 removed from E3.M3 Slice 5 remaining list. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — client displays outcomes; gather resolution stays server-side via **`POST …/interact`**. |
|
||||
| [`docs/manual-qa/NEO-73.md`](../manual-qa/NEO-73.md) | **Matches** — four-node **R** walkthrough, depletion deny, terminal does not overwrite gather line, no manual **I**. |
|
||||
| [`client/README.md`](../../client/README.md) gather feedback section | **Matches** — endpoints, **R** nearest-node rule, HUD labels, boot hydrate; automated test Scope lists NEO-73 suites. |
|
||||
| Full-stack epic decomposition | **Matches** — E3S5-02 **client** counterpart for server NEO-63; Godot manual QA present; does not claim Slice 5 capstone (NEO-75) complete. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Update alignment register + E3.M1 module note on merge** — Plan §8 explicitly deferred [`documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) E3.M1 row and E3.M1 client slice note until implementation completes. Add a **NEO-73 landed** clause (gather HUD, **`skill_progression_client`**, four-node **R** picker) and move NEO-73 off E3.M3 “remaining Slice 5” list.~~ **Done.**
|
||||
|
||||
2. ~~**Clear `_pending_gather_feedback` when interact HTTP fails** — `InteractionRequestClient` only emits **`interaction_result_received`** on parsed **200**; non-200 paths print and return. `main.gd` sets **`_pending_gather_feedback = true`** in **`_forward_interact_resource_nearest`** before POST. If the request fails (500, malformed body, transport error), the flag stays set and a later **`inventory_received`** (e.g. manual **I**) can run **`_finalize_pending_gather_feedback`** with a stale **`_gather_pre_scrap_qty`**, painting a spurious gather delta. Clear the flag on non-200 (new signal or connect to a failure hook) or reset in **`_on_interaction_result_for_gather`** only after a matching success/deny **200**.~~ **Done.** **`interaction_request_failed`** signal + **`_on_interaction_request_failed_for_gather`** clears pending flag.
|
||||
|
||||
3. ~~**`client/README.md` automated tests Scope** — Add **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`**, and extended **`interaction_request_client_test.gd`** to the Scope bullet list (same discoverability fix as NEO-72).~~ **Done.**
|
||||
|
||||
4. ~~**GdUnit: deny path for gather refresh** — `gather_feedback_refresh_test.gd` only asserts the allow → dual-refresh path. A deny mock asserting **zero** inventory/skill sync calls would lock the “no refresh on **`node_depleted`**” contract.~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`Gather: ok — inventory refresh failed`** (`main.gd` **`_on_inventory_sync_failed`**) reads as success; consider **`Gather: inventory refresh failed`** for clarity.~~ **Done.**
|
||||
- Nit: Rapid **R** while a gather POST is in flight overwrites **`_gather_pre_scrap_qty`**; delta can be wrong if two responses complete out of order. Prototype-acceptable; last-wins POST coalescing already exists on the interaction client.
|
||||
- ~~**Bugbot:** Gather feedback flag orphaned when interaction coalesced (**E** overwrites pending **R** POST) — stale **`_pending_gather_feedback`** could paint spurious delta on manual **I**.~~ **Done.** Track **`_gather_pending_interactable_id`**; clear session only when gather never completed.
|
||||
- ~~**Bugbot:** Coalesced POST clears gather feedback before inventory arrives — terminal result after successful **R** cleared pending before inventory GET returned, dropping **`GatherFeedbackLabel`** delta.~~ **Done.** **`_gather_awaiting_inventory_finalize`** preserves session until inventory sync paints delta.
|
||||
- Nit: **`KNOWN_RESOURCE_NODE_IDS`** in **`prototype_interactable_picker.gd`** duplicates the frozen four-node table — intentional catalog-empty fallback; keep in sync with **`E3_M1`** if ids ever change (they are frozen).
|
||||
|
||||
## Verification
|
||||
|
||||
GdUnit (verified on review):
|
||||
|
||||
```bash
|
||||
cd client
|
||||
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
|
||||
-a res://test/skill_progression_client_test.gd \
|
||||
-a res://test/gather_feedback_refresh_test.gd \
|
||||
-a res://test/prototype_interactable_picker_test.gd \
|
||||
-a res://test/interaction_request_client_test.gd
|
||||
```
|
||||
|
||||
**16/16** passed; no `PackedStringArray`/`PackedByteArray` header conversion errors on **`skill_progression_client.gd`** (includes deny + HTTP failure tests after follow-up).
|
||||
|
||||
Manual (required before merge — real **`HTTPRequest`** + four nodes):
|
||||
|
||||
1. `cd server/NeonSprawl.Server && dotnet run`
|
||||
2. Godot **F5** — follow [`docs/manual-qa/NEO-73.md`](../manual-qa/NEO-73.md) (boot salvage row, **R** at each anchor without **I**, depletion deny, terminal **E** leaves gather label unchanged).
|
||||
|
||||
Optional lint (pre-push hook parity):
|
||||
|
||||
```bash
|
||||
gdlint client/scripts client/test
|
||||
gdformat --check client/scripts client/test
|
||||
```
|
||||
Loading…
Reference in New Issue