NEO-73: wire client gather feedback on resource interact.
Add skill progression client, nearest-node R picker, interaction result signal, GatherFeedbackLabel and SkillProgressionLabel HUD.pull/107/head
parent
4682db48c4
commit
aa013d4be7
|
|
@ -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,13 @@
|
|||
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)
|
||||
|
||||
const ID_TERMINAL := "prototype_terminal"
|
||||
const ID_RESOURCE_NODE := "prototype_resource_node_alpha"
|
||||
|
|
@ -44,6 +46,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("/")
|
||||
|
||||
|
|
@ -88,10 +94,14 @@ 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
|
||||
print("Interaction [%s]: HTTP %s body=%s" % [id_echo, response_code, text])
|
||||
|
|
|
|||
|
|
@ -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,11 @@ 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 _pending_gather_feedback: bool = false
|
||||
var _gather_pre_scrap_qty: int = 0
|
||||
|
||||
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
|
||||
@onready var _world: Node3D = $World
|
||||
|
|
@ -91,8 +99,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 +146,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 +367,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 _pending_gather_feedback:
|
||||
_pending_gather_feedback = false
|
||||
_render_gather_feedback_label("Gather: ok — inventory refresh failed")
|
||||
|
||||
|
||||
func _render_inventory_label() -> void:
|
||||
|
|
@ -432,6 +449,128 @@ 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")
|
||||
)
|
||||
_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 {}
|
||||
return _skill_progression_client.call("skill_row", skill_id, _last_progression_snapshot) as Dictionary
|
||||
|
||||
|
||||
func _render_gather_feedback_label(text: String = "Gather: —") -> void:
|
||||
if is_instance_valid(_gather_feedback_label):
|
||||
_gather_feedback_label.text = text
|
||||
|
||||
|
||||
func _on_interaction_result_for_gather(
|
||||
interactable_id: String, allowed: bool, reason_code: String
|
||||
) -> void:
|
||||
if not PrototypeInteractablePicker.is_resource_node_id(_interactables_catalog, interactable_id):
|
||||
return
|
||||
if not allowed:
|
||||
_pending_gather_feedback = false
|
||||
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
|
||||
_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 _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 _pending_gather_feedback:
|
||||
return
|
||||
_pending_gather_feedback = false
|
||||
var new_qty: int = _total_bag_qty(SCRAP_METAL_BULK_ID)
|
||||
var delta: int = new_qty - _gather_pre_scrap_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 +718,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 +727,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 +803,18 @@ 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)
|
||||
_pending_gather_feedback = true
|
||||
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 +868,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,68 @@
|
|||
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,86 @@
|
|||
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 = ""
|
||||
|
||||
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 = (
|
||||
'{"schemaVersion":1,"allowed":true}'.to_utf8_buffer()
|
||||
)
|
||||
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, 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 _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 _on_interaction_result(
|
||||
interactable_id: String, allowed: bool, _reason_code: String
|
||||
) -> void:
|
||||
if not Picker.is_resource_node_id([], interactable_id):
|
||||
return
|
||||
if not allowed:
|
||||
return
|
||||
_inventory.request_sync_from_server()
|
||||
_skill.request_sync_from_server()
|
||||
|
||||
func post_resource_alpha() -> void:
|
||||
_ix.call("post_interact_id", "prototype_resource_node_alpha")
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -12,18 +12,19 @@ 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}'
|
||||
|
||||
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()
|
||||
var body_bytes: PackedByteArray = body_json.to_utf8_buffer()
|
||||
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
|
||||
return OK
|
||||
|
||||
|
|
@ -106,6 +107,31 @@ 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_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,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)")
|
||||
Loading…
Reference in New Issue