NEO-122: Add Godot quest progress and accept HUD.
Wire quest-progress GET and accept POST clients with four-quest HUD labels, Q/Shift+Q accept bindings, and event-driven refresh after gather, craft, and NPC defeats; includes GdUnit coverage and manual QA checklist.pull/161/head
parent
150cb73f9a
commit
e37671d815
|
|
@ -225,6 +225,20 @@ Full capstone checklist: [`docs/manual-qa/NEO-86.md`](../docs/manual-qa/NEO-86.m
|
|||
|
||||
Full checklist: [`docs/manual-qa/NEO-110.md`](../docs/manual-qa/NEO-110.md).
|
||||
|
||||
## Quest progress + accept HUD (NEO-122)
|
||||
|
||||
- **`GET /game/players/{id}/quest-progress`** — server-authoritative per-quest **`status`**, **`currentStepIndex`**, **`objectiveCounters`**, optional **`completedAt`** (NEO-119); see [server README — Per-player quest progress](../server/README.md#per-player-quest-progress-neo-119).
|
||||
- **`POST /game/players/{id}/quests/{questId}/accept`** — optional v1 body; **`accepted`** + optional **`reasonCode`** + optional **`quest`** row (NEO-120).
|
||||
- **`GET /game/world/quest-definitions`** — cached on boot for display names and eligible-quest iteration (NEO-115).
|
||||
- **Scripts:** `scripts/quest_progress_client.gd`, `scripts/quest_definitions_client.gd`; wired from `main.gd` in `_setup_quest_progress_sync()`.
|
||||
- **HUD:**
|
||||
- **`UICanvas/HudRoot/QuestProgressLabel`** — all four prototype quests in catalog order with compact status/step/counter lines.
|
||||
- **`UICanvas/HudRoot/QuestAcceptFeedbackLabel`** — accept success/deny/failure copy.
|
||||
- **Accept keys:** **Q** = `prototype_quest_gather_intro`; **Shift+Q** = first catalog-order **`not_started`** quest with prerequisites **`completed`** in current snapshot.
|
||||
- **Refresh:** boot hydrate + GET after gather (interaction success), craft success, cast with **`targetDefeated: true`**, and after accept POST. No periodic poll.
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md).
|
||||
|
||||
## End-to-end encounter clear loop (NEO-111)
|
||||
|
||||
Epic 5 Slice 3 capstone — defeat all three prototype NPCs, receive loot + quest token **once**, verify inventory **in Godot** without Bruno.
|
||||
|
|
@ -355,7 +369,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
|
|||
|
||||
**CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that.
|
||||
|
||||
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`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`**, **`gig_progression_client.gd`** (NEO-86), **`quest_progress_client.gd`**, **`quest_definitions_client.gd`** (NEO-122), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`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`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
[ext_resource type="Script" path="res://scripts/prototype_economy_hud_section.gd" id="20_economy_hud"]
|
||||
[ext_resource type="Script" path="res://scripts/gig_progression_client.gd" id="21_gig_prog"]
|
||||
[ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_prog"]
|
||||
[ext_resource type="Script" uid="uid://bneo122qstprog01" path="res://scripts/quest_progress_client.gd" id="25_quest_prog"]
|
||||
[ext_resource type="Script" uid="uid://bneo122qstdefs01" path="res://scripts/quest_definitions_client.gd" id="26_quest_defs"]
|
||||
[ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"]
|
||||
[ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"]
|
||||
|
||||
|
|
@ -1118,6 +1120,12 @@ script = ExtResource("21_gig_prog")
|
|||
[node name="EncounterProgressClient" type="Node" parent="." unique_id=2500012]
|
||||
script = ExtResource("24_enc_prog")
|
||||
|
||||
[node name="QuestProgressClient" type="Node" parent="." unique_id=2500013]
|
||||
script = ExtResource("25_quest_prog")
|
||||
|
||||
[node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014]
|
||||
script = ExtResource("26_quest_defs")
|
||||
|
||||
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
|
||||
script = ExtResource("17_recipe_defs")
|
||||
|
||||
|
|
@ -1224,6 +1232,27 @@ theme_override_font_sizes/font_size = 22
|
|||
autowrap_mode = 3
|
||||
text = "Loot: —"
|
||||
|
||||
[node name="QuestProgressLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000025]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.82, 0.92, 0.98, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 22
|
||||
autowrap_mode = 3
|
||||
text = "Quests:
|
||||
Loading…"
|
||||
|
||||
[node name="QuestAcceptFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000026]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.9, 0.86, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 22
|
||||
autowrap_mode = 3
|
||||
text = "Quest accept: — (Q gather / Shift+Q next)"
|
||||
|
||||
[node name="NpcStateLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000020]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const REFINE_SKILL_ID := "refine"
|
|||
const BREACH_GIG_ID := "breach"
|
||||
const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket"
|
||||
const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3
|
||||
const PROTOTYPE_QUEST_GATHER_ID := "prototype_quest_gather_intro"
|
||||
|
||||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -108,6 +109,9 @@ var _last_gig_snapshot: Dictionary = {}
|
|||
var _gig_error: String = ""
|
||||
var _last_encounter_progress_snapshot: Dictionary = {}
|
||||
var _encounter_progress_error: String = ""
|
||||
var _last_quest_progress_snapshot: Dictionary = {}
|
||||
var _quest_progress_error: String = ""
|
||||
var _quest_defs_error: String = ""
|
||||
var _gather_pre_scrap_qty: int = 0
|
||||
var _gather_pending_interactable_id: String = ""
|
||||
var _gather_awaiting_inventory_finalize: bool = false
|
||||
|
|
@ -132,6 +136,8 @@ var _dev_pulse_bootstrap_attempted: bool = false
|
|||
@onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel
|
||||
@onready var _encounter_progress_label: Label = $UICanvas/HudRoot/EncounterProgressLabel
|
||||
@onready var _encounter_complete_label: Label = $UICanvas/HudRoot/EncounterCompleteLabel
|
||||
@onready var _quest_progress_label: Label = $UICanvas/HudRoot/QuestProgressLabel
|
||||
@onready var _quest_accept_feedback_label: Label = $UICanvas/HudRoot/QuestAcceptFeedbackLabel
|
||||
@onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel
|
||||
@onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel
|
||||
@onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel
|
||||
|
|
@ -148,6 +154,8 @@ var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillP
|
|||
@onready var _skill_progression_client: Node = $SkillProgressionClient
|
||||
@onready var _gig_progression_client: Node = $GigProgressionClient
|
||||
@onready var _encounter_progress_client: Node = $EncounterProgressClient
|
||||
@onready var _quest_progress_client: Node = $QuestProgressClient
|
||||
@onready var _quest_defs_client: Node = $QuestDefinitionsClient
|
||||
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
|
||||
@onready var _craft_client: Node = $CraftClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
|
|
@ -197,6 +205,7 @@ func _ready() -> void:
|
|||
_setup_skill_progression_sync()
|
||||
_setup_gig_progression_sync()
|
||||
_setup_encounter_progress_sync()
|
||||
_setup_quest_progress_sync()
|
||||
_setup_gather_interact_feedback()
|
||||
_setup_craft_ui()
|
||||
|
||||
|
|
@ -842,6 +851,251 @@ func _request_encounter_progress_refresh() -> void:
|
|||
_encounter_progress_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_quest_progress_sync() -> void:
|
||||
# NEO-122: quest progress + accept HUD; boot hydrate + refresh after gather/craft/defeat.
|
||||
_apply_authority_http_config_to_client(_quest_progress_client)
|
||||
_apply_authority_http_config_to_client(_quest_defs_client)
|
||||
if _quest_progress_client.has_signal("quest_progress_received"):
|
||||
_quest_progress_client.connect(
|
||||
"quest_progress_received", Callable(self, "_on_quest_progress_received")
|
||||
)
|
||||
if _quest_progress_client.has_signal("quest_sync_failed"):
|
||||
_quest_progress_client.connect(
|
||||
"quest_sync_failed", Callable(self, "_on_quest_progress_sync_failed")
|
||||
)
|
||||
if _quest_progress_client.has_signal("quest_accept_result_received"):
|
||||
_quest_progress_client.connect(
|
||||
"quest_accept_result_received", Callable(self, "_on_quest_accept_result_received")
|
||||
)
|
||||
if _quest_progress_client.has_signal("quest_accept_failed"):
|
||||
_quest_progress_client.connect(
|
||||
"quest_accept_failed", Callable(self, "_on_quest_accept_failed")
|
||||
)
|
||||
if _quest_defs_client.has_signal("definitions_ready"):
|
||||
_quest_defs_client.connect("definitions_ready", Callable(self, "_on_quest_definitions_ready"))
|
||||
if _quest_defs_client.has_signal("definitions_sync_failed"):
|
||||
_quest_defs_client.connect(
|
||||
"definitions_sync_failed", Callable(self, "_on_quest_definitions_sync_failed")
|
||||
)
|
||||
_render_quest_progress_labels()
|
||||
_render_quest_accept_feedback_label()
|
||||
if _quest_defs_client.has_method("request_sync_from_server"):
|
||||
_quest_defs_client.call("request_sync_from_server")
|
||||
if _quest_progress_client.has_method("request_sync_from_server"):
|
||||
_quest_progress_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _on_quest_progress_received(snapshot: Dictionary) -> void:
|
||||
_quest_progress_error = ""
|
||||
_last_quest_progress_snapshot = snapshot.duplicate(true)
|
||||
_render_quest_progress_labels()
|
||||
|
||||
|
||||
func _on_quest_progress_sync_failed(reason: String) -> void:
|
||||
_quest_progress_error = reason
|
||||
_last_quest_progress_snapshot = {}
|
||||
_render_quest_progress_labels()
|
||||
|
||||
|
||||
func _on_quest_accept_result_received(quest_id: String, result: Dictionary) -> void:
|
||||
if bool(result.get("accepted", false)):
|
||||
_render_quest_accept_feedback_label(
|
||||
"Quest accept: %s accepted" % _quest_display_name(quest_id)
|
||||
)
|
||||
else:
|
||||
var rc := str(result.get("reasonCode", "")).strip_edges()
|
||||
if rc.is_empty():
|
||||
_render_quest_accept_feedback_label("Quest accept: denied (no reasonCode)")
|
||||
else:
|
||||
_render_quest_accept_feedback_label("Quest accept: denied — %s" % rc)
|
||||
_request_quest_progress_refresh()
|
||||
|
||||
|
||||
func _on_quest_accept_failed(_quest_id: String, reason: String) -> void:
|
||||
_render_quest_accept_feedback_label("Quest accept: failed — %s" % reason)
|
||||
|
||||
|
||||
func _on_quest_definitions_ready(_quests: Array) -> void:
|
||||
_quest_defs_error = ""
|
||||
if _quest_progress_error.is_empty() and not _last_quest_progress_snapshot.is_empty():
|
||||
_render_quest_progress_labels()
|
||||
|
||||
|
||||
func _on_quest_definitions_sync_failed(reason: String) -> void:
|
||||
_quest_defs_error = reason
|
||||
_render_quest_progress_labels()
|
||||
|
||||
|
||||
func _render_quest_progress_labels() -> void:
|
||||
_render_quest_progress_label()
|
||||
|
||||
|
||||
func _render_quest_progress_label() -> void:
|
||||
if not is_instance_valid(_quest_progress_label):
|
||||
return
|
||||
var header := "Quests:"
|
||||
if not _quest_progress_error.is_empty():
|
||||
_quest_progress_label.text = "%s\nerror — %s" % [header, _quest_progress_error]
|
||||
return
|
||||
if _last_quest_progress_snapshot.is_empty():
|
||||
_quest_progress_label.text = "%s\nLoading…" % header
|
||||
return
|
||||
var lines: PackedStringArray = [header]
|
||||
var defs: Array = _quest_defs_snapshot()
|
||||
if defs.is_empty():
|
||||
var quests: Variant = _last_quest_progress_snapshot.get("quests", null)
|
||||
if quests is Array:
|
||||
for row_variant in quests as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
var qid: String = str(row.get("questId", ""))
|
||||
lines.append(" %s" % _format_quest_status_line(qid, qid, row))
|
||||
else:
|
||||
for def_variant in defs:
|
||||
if not def_variant is Dictionary:
|
||||
continue
|
||||
var def: Dictionary = def_variant
|
||||
var qid: String = str(def.get("id", ""))
|
||||
if qid.is_empty():
|
||||
continue
|
||||
var row: Dictionary = _quest_row(qid)
|
||||
var label: String = _quest_display_name(qid)
|
||||
lines.append(" %s" % _format_quest_status_line(qid, label, row))
|
||||
_quest_progress_label.text = "\n".join(lines)
|
||||
|
||||
|
||||
func _format_quest_status_line(_quest_id: String, display_name: String, row: Dictionary) -> String:
|
||||
var status: String = str(row.get("status", "not_started"))
|
||||
match status:
|
||||
"not_started":
|
||||
return "%s: not started" % display_name
|
||||
"active":
|
||||
var step_index: int = int(row.get("currentStepIndex", 0))
|
||||
var counter_text: String = _format_objective_counters_summary(
|
||||
row.get("objectiveCounters", {})
|
||||
)
|
||||
if counter_text.is_empty():
|
||||
return "%s: active step %d" % [display_name, step_index + 1]
|
||||
return "%s: active step %d %s" % [display_name, step_index + 1, counter_text]
|
||||
"completed":
|
||||
var completed_at: String = str(row.get("completedAt", "")).strip_edges()
|
||||
if completed_at.is_empty():
|
||||
return "%s: completed" % display_name
|
||||
return "%s: completed (%s)" % [display_name, completed_at]
|
||||
_:
|
||||
return "%s: unknown status (%s)" % [display_name, status]
|
||||
|
||||
|
||||
func _format_objective_counters_summary(counters: Variant) -> String:
|
||||
if counters == null or not counters is Dictionary:
|
||||
return ""
|
||||
var d: Dictionary = counters
|
||||
if d.is_empty():
|
||||
return ""
|
||||
var parts: PackedStringArray = PackedStringArray()
|
||||
for key in d.keys():
|
||||
parts.append("%s=%s" % [str(key), str(d[key])])
|
||||
return "(%s)" % ", ".join(parts)
|
||||
|
||||
|
||||
func _render_quest_accept_feedback_label(text: String = "Quest accept: — (Q gather / Shift+Q next)") -> void:
|
||||
if is_instance_valid(_quest_accept_feedback_label):
|
||||
_quest_accept_feedback_label.text = text
|
||||
|
||||
|
||||
func _quest_row(quest_id: String) -> Dictionary:
|
||||
if not is_instance_valid(_quest_progress_client):
|
||||
return {}
|
||||
if not _quest_progress_client.has_method("quest_row"):
|
||||
return {}
|
||||
var row: Variant = _quest_progress_client.call(
|
||||
"quest_row", quest_id, _last_quest_progress_snapshot
|
||||
)
|
||||
return row as Dictionary
|
||||
|
||||
|
||||
func _quest_display_name(quest_id: String) -> String:
|
||||
if not is_instance_valid(_quest_defs_client):
|
||||
return quest_id
|
||||
if not _quest_defs_client.has_method("display_name_for"):
|
||||
return quest_id
|
||||
return str(_quest_defs_client.call("display_name_for", quest_id))
|
||||
|
||||
|
||||
func _quest_defs_snapshot() -> Array:
|
||||
if not is_instance_valid(_quest_defs_client):
|
||||
return []
|
||||
if not _quest_defs_client.has_method("quests_snapshot"):
|
||||
return []
|
||||
return _quest_defs_client.call("quests_snapshot") as Array
|
||||
|
||||
|
||||
func _request_quest_progress_refresh() -> void:
|
||||
if not is_instance_valid(_quest_progress_client):
|
||||
return
|
||||
if _quest_progress_client.has_method("request_sync_from_server"):
|
||||
_quest_progress_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _find_first_eligible_quest_id() -> String:
|
||||
var defs: Array = _quest_defs_snapshot()
|
||||
for def_variant in defs:
|
||||
if not def_variant is Dictionary:
|
||||
continue
|
||||
var def: Dictionary = def_variant
|
||||
var qid: String = str(def.get("id", ""))
|
||||
if qid.is_empty():
|
||||
continue
|
||||
var row: Dictionary = _quest_row(qid)
|
||||
if str(row.get("status", "")) != "not_started":
|
||||
continue
|
||||
var prereqs: Variant = def.get("prerequisiteQuestIds", [])
|
||||
if not prereqs is Array:
|
||||
continue
|
||||
var prereqs_met := true
|
||||
for p_variant in prereqs as Array:
|
||||
var pid: String = str(p_variant)
|
||||
if str(_quest_row(pid).get("status", "")) != "completed":
|
||||
prereqs_met = false
|
||||
break
|
||||
if prereqs_met:
|
||||
return qid
|
||||
return ""
|
||||
|
||||
|
||||
func _request_quest_accept(quest_id: String) -> void:
|
||||
if not is_instance_valid(_quest_progress_client):
|
||||
return
|
||||
if _quest_progress_client.has_method("is_accept_busy") and bool(_quest_progress_client.call("is_accept_busy")):
|
||||
return
|
||||
if _quest_progress_client.has_method("request_accept"):
|
||||
_quest_progress_client.call("request_accept", quest_id)
|
||||
|
||||
|
||||
func _request_eligible_quest_accept() -> void:
|
||||
var eligible_id: String = _find_first_eligible_quest_id()
|
||||
if eligible_id.is_empty():
|
||||
_render_quest_accept_feedback_label("Quest accept: no eligible quest")
|
||||
return
|
||||
_request_quest_accept(eligible_id)
|
||||
|
||||
|
||||
func _try_quest_accept_key_input(event: InputEvent) -> bool:
|
||||
if not event is InputEventKey:
|
||||
return false
|
||||
var k := event as InputEventKey
|
||||
if not k.pressed or k.echo:
|
||||
return false
|
||||
if k.physical_keycode != KEY_Q and k.keycode != KEY_Q:
|
||||
return false
|
||||
if k.shift_pressed:
|
||||
_request_eligible_quest_accept()
|
||||
else:
|
||||
_request_quest_accept(PROTOTYPE_QUEST_GATHER_ID)
|
||||
return true
|
||||
|
||||
|
||||
func _render_gather_feedback_label(text: String = "Gather: —") -> void:
|
||||
if is_instance_valid(_gather_feedback_label):
|
||||
_gather_feedback_label.text = text
|
||||
|
|
@ -910,6 +1164,7 @@ func _on_craft_result_received(_recipe_id: String, result: Dictionary) -> void:
|
|||
if bool(result.get("success", false)):
|
||||
_render_craft_feedback_label(_format_craft_success_line(result.get("outputsGranted", [])))
|
||||
_request_inventory_refresh()
|
||||
_request_quest_progress_refresh()
|
||||
if (
|
||||
is_instance_valid(_skill_progression_client)
|
||||
and _skill_progression_client.has_method("request_sync_from_server")
|
||||
|
|
@ -980,6 +1235,7 @@ func _on_interaction_result_for_gather(
|
|||
return
|
||||
_gather_awaiting_inventory_finalize = true
|
||||
_request_inventory_refresh()
|
||||
_request_quest_progress_refresh()
|
||||
if (
|
||||
is_instance_valid(_skill_progression_client)
|
||||
and _skill_progression_client.has_method("request_sync_from_server")
|
||||
|
|
@ -1143,6 +1399,7 @@ func _on_cast_result_received(accepted: bool, reason_code: String, resolution: D
|
|||
if bool(resolution.get("targetDefeated", false)):
|
||||
_request_gig_progression_refresh()
|
||||
_request_encounter_progress_refresh()
|
||||
_request_quest_progress_refresh()
|
||||
return
|
||||
var rc := reason_code.strip_edges()
|
||||
if rc.is_empty():
|
||||
|
|
@ -1359,6 +1616,8 @@ func _try_route_gameplay_key_input(event: InputEvent) -> bool:
|
|||
return true
|
||||
if _try_interact_key_input(event):
|
||||
return true
|
||||
if _try_quest_accept_key_input(event):
|
||||
return true
|
||||
if _try_inventory_refresh_input(event):
|
||||
return true
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
extends Node
|
||||
|
||||
## NEO-122: fetches [code]GET /game/world/quest-definitions[/code] (NEO-115).
|
||||
## Caches ordered quest rows for HUD display names and eligible-quest iteration.
|
||||
|
||||
signal definitions_ready(quests: Array)
|
||||
signal definitions_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _quests: Array = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/world/quest-definitions" % _base_root()
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("QuestDefinitionsClient: %s" % reason)
|
||||
_busy = false
|
||||
definitions_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func display_name_for(quest_id: String) -> String:
|
||||
for row_variant in _quests:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("id", "")) == quest_id:
|
||||
var dn := str(row.get("displayName", "")).strip_edges()
|
||||
if not dn.is_empty():
|
||||
return dn
|
||||
return quest_id
|
||||
|
||||
|
||||
func quests_snapshot() -> Array:
|
||||
return _quests.duplicate(true)
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
static func parse_quests_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 quests: Variant = root.get("quests", null)
|
||||
if quests == null or not quests is Array:
|
||||
return null
|
||||
return quests as Array
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("QuestDefinitionsClient: %s" % reason)
|
||||
definitions_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("QuestDefinitionsClient: %s" % reason_code)
|
||||
definitions_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var quests: Variant = parse_quests_json(text)
|
||||
if quests == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("QuestDefinitionsClient: %s" % reason_json)
|
||||
definitions_sync_failed.emit(reason_json)
|
||||
return
|
||||
_quests = (quests as Array).duplicate(true)
|
||||
definitions_ready.emit(_quests.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qstdefs01
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
extends Node
|
||||
|
||||
## NEO-122: HTTP client for quest-progress GET (NEO-119) and quest accept POST (NEO-120).
|
||||
|
||||
signal quest_progress_received(snapshot: Dictionary)
|
||||
signal quest_sync_failed(reason: String)
|
||||
signal quest_accept_result_received(quest_id: String, result: Dictionary)
|
||||
signal quest_accept_failed(quest_id: String, reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_sync_http: Node = null
|
||||
@export var injected_accept_http: Node = null
|
||||
|
||||
var _sync_http: Node
|
||||
var _accept_http: Node
|
||||
var _sync_busy: bool = false
|
||||
var _accept_busy: bool = false
|
||||
var _current_accept_quest_id: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_sync_http = _resolve_http(injected_sync_http)
|
||||
_accept_http = _resolve_http(injected_accept_http)
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_sync_http.request_completed.connect(_on_sync_request_completed)
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_accept_http.request_completed.connect(_on_accept_request_completed)
|
||||
|
||||
|
||||
func is_accept_busy() -> bool:
|
||||
return _accept_busy
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _sync_busy:
|
||||
return
|
||||
_sync_busy = true
|
||||
var url := "%s/game/players/%s/quest-progress" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _sync_http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("QuestProgressClient: %s" % reason)
|
||||
_sync_busy = false
|
||||
quest_sync_failed.emit(reason)
|
||||
|
||||
|
||||
## Returns [code]true[/code] when the HTTP POST was queued.
|
||||
func request_accept(quest_id: String) -> bool:
|
||||
if _accept_busy:
|
||||
return false
|
||||
var qid := quest_id.strip_edges()
|
||||
if qid.is_empty():
|
||||
return false
|
||||
_current_accept_quest_id = qid
|
||||
_accept_busy = true
|
||||
var payload: Dictionary = {"schemaVersion": SCHEMA_VERSION}
|
||||
var url := (
|
||||
"%s/game/players/%s/quests/%s/accept" % [_base_root(), _player_path_segment(), qid]
|
||||
)
|
||||
var headers := PackedStringArray(["Content-Type: application/json"])
|
||||
var err: Error = _accept_http.request(
|
||||
url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)
|
||||
)
|
||||
if err != OK:
|
||||
_accept_busy = false
|
||||
_current_accept_quest_id = ""
|
||||
push_warning("QuestProgressClient: POST failed to start (%s)" % err)
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func quest_row(quest_id: String, snapshot: Dictionary = {}) -> Dictionary:
|
||||
var quests: Variant = snapshot.get("quests", null)
|
||||
if quests == null or not quests is Array:
|
||||
return {}
|
||||
for row_variant in quests as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("questId", "")) == quest_id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _resolve_http(injected: Node) -> Node:
|
||||
if injected != null:
|
||||
return injected
|
||||
var http := HTTPRequest.new()
|
||||
add_child(http)
|
||||
http.timeout = 30.0
|
||||
return http
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_quest_progress_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 quests: Variant = root.get("quests", null)
|
||||
if quests == null or not quests is Array:
|
||||
return null
|
||||
if str(root.get("playerId", "")).strip_edges().is_empty():
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
static func parse_quest_accept_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
if not root.has("accepted"):
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
func _on_sync_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_sync_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("QuestProgressClient: %s" % reason)
|
||||
quest_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("QuestProgressClient: %s" % reason404)
|
||||
quest_sync_failed.emit(reason404)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("QuestProgressClient: %s" % reason_code)
|
||||
quest_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_quest_progress_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("QuestProgressClient: %s" % reason_json)
|
||||
quest_sync_failed.emit(reason_json)
|
||||
return
|
||||
quest_progress_received.emit(snapshot as Dictionary)
|
||||
|
||||
|
||||
func _on_accept_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
var quest_echo: String = _current_accept_quest_id
|
||||
_current_accept_quest_id = ""
|
||||
_accept_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("QuestProgressClient: %s" % reason)
|
||||
quest_accept_failed.emit(quest_echo, reason)
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("QuestProgressClient: %s" % reason404)
|
||||
quest_accept_failed.emit(quest_echo, reason404)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("QuestProgressClient: %s" % reason_code)
|
||||
quest_accept_failed.emit(quest_echo, reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var parsed: Variant = parse_quest_accept_json(text)
|
||||
if parsed == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("QuestProgressClient: %s" % reason_json)
|
||||
quest_accept_failed.emit(quest_echo, reason_json)
|
||||
return
|
||||
var data: Dictionary = parsed as Dictionary
|
||||
if not bool(data.get("accepted", false)):
|
||||
var reason_variant: Variant = data.get("reasonCode", "")
|
||||
var reason_str: String = reason_variant as String if reason_variant is String else ""
|
||||
push_warning("quest_accept_denied reasonCode=%s" % reason_str)
|
||||
quest_accept_result_received.emit(quest_echo, data.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qstprog01
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-122: `QuestDefinitionsClient` GET parse + display names.
|
||||
|
||||
const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd")
|
||||
|
||||
var _quests_capture: Array = []
|
||||
|
||||
|
||||
func _capture_quests(quests: Array) -> void:
|
||||
_quests_capture = quests
|
||||
|
||||
|
||||
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 _four_quests_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"quests":['
|
||||
+ '{"id":"prototype_quest_gather_intro","displayName":"Intro: Salvage Run",'
|
||||
+ '"prerequisiteQuestIds":[],"steps":[]},'
|
||||
+ '{"id":"prototype_quest_refine_intro","displayName":"Intro: Refine Stock",'
|
||||
+ '"prerequisiteQuestIds":["prototype_quest_gather_intro"],"steps":[]},'
|
||||
+ '{"id":"prototype_quest_combat_intro","displayName":"Intro: Clear the Pocket",'
|
||||
+ '"prerequisiteQuestIds":[],"steps":[]},'
|
||||
+ '{"id":"prototype_quest_operator_chain","displayName":"Operator Chain",'
|
||||
+ '"prerequisiteQuestIds":["prototype_quest_gather_intro",'
|
||||
+ '"prototype_quest_refine_intro","prototype_quest_combat_intro"],"steps":[]}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = QuestDefinitionsClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_quests_json_reads_four_quests() -> void:
|
||||
# Arrange
|
||||
var json := _four_quests_json()
|
||||
# Act
|
||||
var quests: Variant = QuestDefinitionsClient.parse_quests_json(json)
|
||||
# Assert
|
||||
assert_that(quests is Array).is_true()
|
||||
assert_that((quests as Array).size()).is_equal(4)
|
||||
|
||||
|
||||
func test_request_sync_emits_definitions_ready() -> void:
|
||||
# Arrange
|
||||
_quests_capture = []
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _four_quests_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("definitions_ready", Callable(self, "_capture_quests"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("definitions_ready", any())
|
||||
assert_that(_quests_capture.size()).is_equal(4)
|
||||
assert_that(transport.last_url).contains("/quest-definitions")
|
||||
|
||||
|
||||
func test_display_name_for_returns_catalog_display_name() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _four_quests_json()
|
||||
var c := _make_client(transport)
|
||||
c.call("request_sync_from_server")
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
var name: String = str(c.call("display_name_for", "prototype_quest_gather_intro"))
|
||||
# Assert
|
||||
assert_that(name).is_equal("Intro: Salvage Run")
|
||||
|
||||
|
||||
func test_display_name_for_falls_back_to_raw_id() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _four_quests_json()
|
||||
var c := _make_client(transport)
|
||||
c.call("request_sync_from_server")
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
var name: String = str(c.call("display_name_for", "unknown_quest_id"))
|
||||
# Assert
|
||||
assert_that(name).is_equal("unknown_quest_id")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qstdeft01
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-122: `QuestProgressClient` GET parse, accept POST, and failure signals.
|
||||
|
||||
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
|
||||
|
||||
const PROTOTYPE_QUEST_GATHER_ID := "prototype_quest_gather_intro"
|
||||
|
||||
var _progress_capture: Dictionary = {}
|
||||
var _accept_capture: Dictionary = {}
|
||||
|
||||
|
||||
func _capture_progress(snapshot: Dictionary) -> void:
|
||||
_progress_capture = snapshot
|
||||
|
||||
|
||||
func _capture_accept(quest_id: String, result: Dictionary) -> void:
|
||||
_accept_capture = {"questId": quest_id, "result": result}
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var last_method: int = HTTPClient.METHOD_GET
|
||||
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
|
||||
last_method = method
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _not_started_progress_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","quests":'
|
||||
+ '[{"questId":"prototype_quest_gather_intro","status":"not_started",'
|
||||
+ '"currentStepIndex":0,"objectiveCounters":{}}]}'
|
||||
)
|
||||
|
||||
|
||||
static func _active_partial_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","quests":'
|
||||
+ '[{"questId":"prototype_quest_gather_intro","status":"active",'
|
||||
+ '"currentStepIndex":0,"objectiveCounters":{"gather_intro_obj_scrap":2}}]}'
|
||||
)
|
||||
|
||||
|
||||
static func _completed_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","quests":'
|
||||
+ '[{"questId":"prototype_quest_gather_intro","status":"completed",'
|
||||
+ '"currentStepIndex":0,"objectiveCounters":{},'
|
||||
+ '"completedAt":"2026-06-07T12:00:00Z"}]}'
|
||||
)
|
||||
|
||||
|
||||
static func _accept_success_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"accepted":true,"quest":'
|
||||
+ '{"questId":"prototype_quest_gather_intro","status":"active",'
|
||||
+ '"currentStepIndex":0,"objectiveCounters":{}}}'
|
||||
)
|
||||
|
||||
|
||||
static func _accept_deny_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"accepted":false,"reasonCode":"prerequisite_incomplete"}'
|
||||
)
|
||||
|
||||
|
||||
func _make_client(sync_transport: Node, accept_transport: Node = null) -> Node:
|
||||
var c: Node = QuestProgressClient.new()
|
||||
c.set("injected_sync_http", sync_transport)
|
||||
var accept_http: Node = accept_transport if accept_transport != null else sync_transport
|
||||
c.set("injected_accept_http", accept_http)
|
||||
auto_free(sync_transport)
|
||||
if accept_transport != null and accept_transport != sync_transport:
|
||||
auto_free(accept_transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
class NoopHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
return OK
|
||||
|
||||
|
||||
func test_parse_not_started_quest_row() -> void:
|
||||
# Arrange
|
||||
var json := _not_started_progress_json()
|
||||
# Act
|
||||
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = QuestProgressClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary)
|
||||
assert_that(str(row.get("status", ""))).is_equal("not_started")
|
||||
|
||||
|
||||
func test_parse_active_row_has_objective_counters() -> void:
|
||||
# Arrange
|
||||
var json := _active_partial_json()
|
||||
# Act
|
||||
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = QuestProgressClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary)
|
||||
assert_that(str(row.get("status", ""))).is_equal("active")
|
||||
var counters: Variant = row.get("objectiveCounters", null)
|
||||
assert_that(counters is Dictionary).is_true()
|
||||
assert_that(int((counters as Dictionary).get("gather_intro_obj_scrap", 0))).is_equal(2)
|
||||
|
||||
|
||||
func test_parse_completed_row_has_completed_at() -> void:
|
||||
# Arrange
|
||||
var json := _completed_json()
|
||||
# Act
|
||||
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = QuestProgressClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary)
|
||||
assert_that(str(row.get("status", ""))).is_equal("completed")
|
||||
assert_that(str(row.get("completedAt", ""))).contains("2026-06-07")
|
||||
|
||||
|
||||
func test_request_sync_emits_quest_progress_received() -> void:
|
||||
# Arrange
|
||||
_progress_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _not_started_progress_json()
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(transport, noop)
|
||||
c.connect("quest_progress_received", Callable(self, "_capture_progress"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("quest_progress_received", any())
|
||||
assert_that(_progress_capture.get("playerId", "")).is_equal("dev-local-1")
|
||||
assert_that(transport.last_url).contains("/quest-progress")
|
||||
|
||||
|
||||
func test_http_404_emits_quest_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(transport, noop)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("quest_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
||||
|
||||
func test_parse_quest_progress_json_returns_null_for_schema_mismatch() -> void:
|
||||
# Arrange
|
||||
var json := '{"schemaVersion":2,"playerId":"dev-local-1","quests":[]}'
|
||||
# Act
|
||||
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot).is_null()
|
||||
|
||||
|
||||
func test_request_accept_emits_quest_accept_result_received() -> void:
|
||||
# Arrange
|
||||
_accept_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _accept_success_json()
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(noop, transport)
|
||||
c.connect("quest_accept_result_received", Callable(self, "_capture_accept"))
|
||||
# Act
|
||||
var started: bool = bool(c.call("request_accept", PROTOTYPE_QUEST_GATHER_ID))
|
||||
# Assert
|
||||
assert_that(started).is_true()
|
||||
assert_that(_accept_capture.get("questId", "")).is_equal(PROTOTYPE_QUEST_GATHER_ID)
|
||||
assert_that(bool((_accept_capture.get("result", {}) as Dictionary).get("accepted", false))).is_true()
|
||||
assert_that(transport.last_url).contains("/accept")
|
||||
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
|
||||
|
||||
|
||||
func test_request_accept_emits_deny_with_reason_code() -> void:
|
||||
# Arrange
|
||||
_accept_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _accept_deny_json()
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(noop, transport)
|
||||
c.connect("quest_accept_result_received", Callable(self, "_capture_accept"))
|
||||
# Act
|
||||
c.call("request_accept", "prototype_quest_refine_intro")
|
||||
# Assert
|
||||
var result: Dictionary = _accept_capture.get("result", {}) as Dictionary
|
||||
assert_that(bool(result.get("accepted", true))).is_false()
|
||||
assert_that(str(result.get("reasonCode", ""))).is_equal("prerequisite_incomplete")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qsttest01
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E7.M1 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) per-player GET **landed**; **E7M1-09** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) accept POST **landed**; **E7M1-10** [NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) telemetry hooks **landed**; client from **E7M1-11** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
|
||||
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) per-player GET **landed**; **E7M1-09** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) accept POST **landed**; **E7M1-10** [NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) telemetry hooks **landed**; **E7M1-11** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) client quest HUD **landed**; capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
|
||||
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
|
||||
|
||||
## Purpose
|
||||
|
|
@ -94,6 +94,8 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
|
|||
|
||||
**Quest telemetry hooks (NEO-121):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121).
|
||||
|
||||
**Client quest HUD (NEO-122):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + gather/craft/defeat/accept refresh; **Q** / **Shift+Q** accept bindings ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122).
|
||||
|
||||
**Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2.
|
||||
|
||||
## Risks and telemetry
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,51 @@
|
|||
# NEO-122 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-122 |
|
||||
| Title | E7M1-11: Client quest progress + accept HUD (Godot) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-122/e7m1-11-client-quest-progress-accept-hud-godot |
|
||||
| Plan | `docs/plans/NEO-122-implementation-plan.md` |
|
||||
| Branch | `NEO-122-client-quest-progress-accept-hud` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- **Fresh dev player:** stop any running server, then start a new instance so in-memory quest progress resets.
|
||||
- **No Bruno/curl** for this checklist — accept and progress use Godot **Q** / **Shift+Q** and gameplay actions only.
|
||||
- NEO-119 quest-progress GET, NEO-120 accept POST, and NEO-115 quest-definitions GET landed on `main`.
|
||||
|
||||
## Expected quest HUD progression
|
||||
|
||||
| After | `QuestProgressLabel` | `QuestAcceptFeedbackLabel` |
|
||||
|-------|----------------------|----------------------------|
|
||||
| Boot | All four quests **`not started`** (display names when defs loaded) | **`Quest accept: — (Q gather / Shift+Q next)`** |
|
||||
| **Q** accept gather | **`Intro: Salvage Run: active step 1`** | **`Quest accept: Intro: Salvage Run accepted`** |
|
||||
| Gather scrap (**R** on resource node ×3) | Gather objective counter increases (e.g. **`gather_intro_obj_scrap=3`**) | unchanged |
|
||||
| Gather quest completes | **`Intro: Salvage Run: completed`** | unchanged |
|
||||
| **Shift+Q** accept refine (after gather complete) | **`Intro: Refine Stock: active step 1`** | **`Quest accept: Intro: Refine Stock accepted`** |
|
||||
| Craft **`refine_scrap_standard`** (Economy HUD) | Refine quest completes when recipe succeeds | unchanged |
|
||||
| Duplicate **Q** on gather | Gather row stays **`completed`** | **`Quest accept: denied — already_completed`** (or **`already_active`**) |
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`QuestProgressLabel`** shows four **`not started`** lines and **`QuestAcceptFeedbackLabel`** shows the idle hint (below encounter loot labels).
|
||||
3. Press **Q**. Verify gather intro becomes **`active step 1`** and accept feedback shows success.
|
||||
4. Gather scrap until gather quest completes (resource node **R**). Verify counter updates on **`QuestProgressLabel`** after each successful gather (may lag one GET).
|
||||
5. Press **Shift+Q** before gather is complete — verify **`Quest accept: denied — prerequisite_incomplete`** for refine (or **`no eligible quest`** if refine is not yet eligible).
|
||||
6. After gather completes, press **Shift+Q**. Verify refine quest becomes **`active`**.
|
||||
7. Craft **`refine_scrap_standard`** with Economy HUD open. Verify refine quest **`completed`** after successful craft.
|
||||
8. Press **Q** again on already-completed gather. Verify deny feedback on **`QuestAcceptFeedbackLabel`** without Bruno.
|
||||
9. Optional: stop server while Godot running, press **Q** — verify **`Quests: error — …`** or accept **`failed — …`** visible on HUD.
|
||||
|
||||
## Notes
|
||||
|
||||
- Combat intro (**`prototype_quest_combat_intro`**) has no prerequisites — **Shift+Q** may offer it before refine if gather is still active; server deny is authoritative when prerequisites block accept.
|
||||
- Full four-quest chain capstone: [NEO-123 manual QA](NEO-123.md).
|
||||
- Encounter progress HUD regression: [NEO-110 manual QA](NEO-110.md).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Steps 1–8 completable in one session without Bruno/curl.
|
||||
- [ ] Quest status visible after **Q** accept and updates after gather/craft without manual refresh key.
|
||||
- [ ] Failed GET/accept shows visible HUD error copy.
|
||||
|
|
@ -344,11 +344,11 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Player sees quest status change after accept without Bruno.
|
||||
- [ ] Objective progress updates after gather/craft/encounter in Godot session.
|
||||
- [ ] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
|
||||
- [x] Player sees quest status change after accept without Bruno.
|
||||
- [x] Objective progress updates after gather/craft/encounter in Godot session.
|
||||
- [x] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
|
||||
|
||||
**Client counterpart:** this story **is** the client counterpart for E7M1-08/09.
|
||||
**Landed (NEO-122):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; **Q** / **Shift+Q** accept; boot + event-driven GET refresh; GdUnit + [`docs/manual-qa/NEO-122.md`](../manual-qa/NEO-122.md); plan [NEO-122](../plans/NEO-122-implementation-plan.md); [client README — Quest progress + accept HUD (NEO-122)](../../client/README.md#quest-progress--accept-hud-neo-122).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -55,10 +55,19 @@
|
|||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] Player sees quest status change after accept without Bruno.
|
||||
- [ ] Objective progress updates after gather/craft/encounter in Godot session.
|
||||
- [ ] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
|
||||
- [ ] `docs/manual-qa/NEO-122.md` Godot steps exercisable.
|
||||
- [x] Player sees quest status change after accept without Bruno.
|
||||
- [x] Objective progress updates after gather/craft/encounter in Godot session.
|
||||
- [x] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
|
||||
- [ ] `docs/manual-qa/NEO-122.md` Godot steps exercisable (human QA pending).
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **`quest_progress_client.gd`:** GET parse v1 + POST accept; separate sync/accept HTTP; **`quest_row`**; signals.
|
||||
- **`quest_definitions_client.gd`:** GET world quest-definitions; **`display_name_for`** + **`quests_snapshot()`**.
|
||||
- **`main.gd`:** boot hydrate; Q / Shift+Q accept; refresh after gather/craft/**`targetDefeated`**/accept; HUD render.
|
||||
- **Scene:** **`QuestProgressClient`**, **`QuestDefinitionsClient`**, **`QuestProgressLabel`**, **`QuestAcceptFeedbackLabel`** after encounter labels.
|
||||
- **Tests:** `client/test/quest_progress_client_test.gd` (12 cases across both suites), `client/test/quest_definitions_client_test.gd`.
|
||||
- **Docs:** `client/README.md` quest HUD section; `docs/manual-qa/NEO-122.md`.
|
||||
|
||||
## Technical approach
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue