Compare commits

...

8 Commits

Author SHA1 Message Date
VinPropane 33cfc62c66
Merge pull request #171 from ViPro-Technologies/NEO-132-playable-quest-reward-delivery-capstone
NEO-132: Playable quest reward delivery capstone (Godot)
2026-06-14 14:33:33 -04:00
VinPropane fa9396323e NEO-132: Fix economy refresh signal test matchers
quest_completion_reward_transition emits quest_id and summary; GdUnit
is_emitted needs any() for both args or the assertion times out in CI.
2026-06-14 14:24:14 -04:00
VinPropane e2991a2926 NEO-132: queue economy sync while clients are busy
Gather/craft start inventory and skill GETs in parallel with quest-progress;
post-completion _refresh_economy_hud was dropped when those clients were _busy.
Match quest_progress_client pending-sync flush so a follow-up GET runs after
the in-flight request completes.
2026-06-14 14:19:53 -04:00
VinPropane acf3d079d2 NEO-132: Move economy refresh into quest HUD controller for lint budget.
Pass inventory and skill clients into setup; refresh on completion
transition in quest_hud_controller.gd; fix line-length and file-size
gdlint limits for pre-push.
2026-06-14 14:15:46 -04:00
VinPropane 726926b901 NEO-132: Fix pre-push lint (main.gd line budget, test line length).
Wire economy refresh via setup callback; split long lines; add callback
GdUnit coverage.
2026-06-14 14:15:18 -04:00
VinPropane 89c8f3034c NEO-132: Fix pre-push lint (main.gd line budget, test line length).
Wire economy refresh via setup callback instead of signal connect;
wrap long GdUnit assert line.
2026-06-14 14:14:54 -04:00
VinPropane 63156c353f NEO-132: Ship Slice 2 quest reward capstone docs and economy refresh.
Add NEO-132 manual QA extending NEO-123 with reward HUD and economy
verification, README capstone section, E7.M2 Ready alignment, client
completion-transition signal for inventory/skill refresh, and GdUnit
coverage for the new signal.
2026-06-14 14:08:02 -04:00
VinPropane a1947d8f05 NEO-132: Add kickoff implementation plan for E7M2 capstone.
Docs-primary Slice 2 capstone extending NEO-123 flow with reward HUD
and economy verification; conditional client refresh fixes if QA fails.
2026-06-14 14:06:30 -04:00
16 changed files with 602 additions and 7 deletions

View File

@ -246,11 +246,39 @@ Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md).
- **HUD:** - **HUD:**
- **`UICanvas/HudRootScroll/HudRoot/QuestRewardDeliveryLabel`** — **`Quest rewards: —`** until a quest newly becomes **`completed`** in-session; then quest display name + grant lines. - **`UICanvas/HudRootScroll/HudRoot/QuestRewardDeliveryLabel`** — **`Quest rewards: —`** until a quest newly becomes **`completed`** in-session; then quest display name + grant lines.
- Item lines use **`ItemDefinitionsClient.display_name_for`** (NEO-110 loot precedent); skill XP lines use title-case **`skillId`** + amount (e.g. **`Salvage +25 XP`**). - Item lines use **`ItemDefinitionsClient.display_name_for`** (NEO-110 loot precedent); skill XP lines use title-case **`skillId`** + amount (e.g. **`Salvage +25 XP`**).
- **Refresh:** reuses NEO-122 triggers (boot hydrate + gather/craft/defeat/accept). **Transition-only** — boot with already-completed quests does not replay grant copy (NEO-123 idempotency). - **Refresh:** reuses NEO-122 triggers (boot hydrate + gather/craft/defeat/accept). **Transition-only** — boot with already-completed quests does not replay grant copy (NEO-123 idempotency). **Economy HUD** (inventory + skills) refreshes on completion transition via NEO-132 signal from `quest_hud_controller.gd`.
- **Errors:** failed quest-progress GET shows **`sync error — …`** on **`QuestProgressLabel`**; reward label **`—`** (NEO-122 pattern). - **Errors:** failed quest-progress GET shows **`sync error — …`** on **`QuestProgressLabel`**; reward label **`—`** (NEO-122 pattern).
Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md). Full checklist: [`docs/manual-qa/NEO-131.md`](../docs/manual-qa/NEO-131.md).
## End-to-end quest reward loop (NEO-132)
Epic 7 Slice 2 capstone — complete all four prototype quests **in Godot** and verify quest completion bundles (skill XP + item grants) apply **once** with idempotent replay.
**Flow:** fresh server restart → NEO-123 four-quest path → per-completion **`QuestRewardDeliveryLabel`** + economy HUD verification → Godot restart idempotency.
| Step | Input / trigger | HUD / server outcome |
|------|-----------------|----------------------|
| Boot | Godot **F5** + fresh server | Four quests **`not started`**; reward label **`—`**; skill xp **0** |
| Gather intro | **Q** + gather ×3 scrap | Salvage Run **`completed`**; **`Salvage +25 XP`** reward line; salvage xp **+25** |
| Combat intro | **Shift+Q** + defeat ×3 NPCs | Clear the Pocket **`completed`**; encounter loot on **`EncounterCompleteLabel`**; **`Salvage +25 XP`** reward line; salvage xp **+25** |
| Refine intro | **Shift+Q** + craft **`refine_scrap_standard`** | Refine Stock **`completed`**; **`Refine +25 XP`** reward line; refine xp **+25** |
| Operator chain | **Shift+Q** + chain objectives | Operator Chain **`completed`**; **`Survey Drone Kit ×1`** + **`Salvage +50 XP`**; bag contains kit |
| Idempotency | Stop Godot + **F5** (server still running) | Quests still **`completed`**; reward label **`—`**; economy counts unchanged |
| Duplicate accept | **Q** / **Shift+Q** on finished quests | Deny; no duplicate skill XP or items |
**Reward vs encounter loot:** quest completion bundles (`completionRewardBundle`) surface on **`QuestRewardDeliveryLabel`**; encounter pocket loot remains on **`EncounterCompleteLabel`** (E7M2 freeze — encounter loot unchanged).
**Economy refresh:** on in-session quest completion transition, **`InventoryLabel`** + **`SkillProgressionLabel`** auto-refresh (NEO-132); reward label paint remains transition-only (NEO-131).
**Cross-links:** [NEO-124](../docs/plans/NEO-124-implementation-plan.md) catalog freeze · [NEO-126](../docs/plans/NEO-126-implementation-plan.md) delivery store · [NEO-127](../docs/plans/NEO-127-implementation-plan.md) router · [NEO-129](../docs/plans/NEO-129-implementation-plan.md) HTTP summary · [NEO-131](../docs/manual-qa/NEO-131.md) reward HUD · [NEO-123](../docs/manual-qa/NEO-123.md) onboarding flow.
**Scripts:** `quest_hud_controller.gd` (completion transition signal → economy refresh), `quest_progress_client.gd`, `inventory_client.gd`, `skill_progression_client.gd` — wired from `main.gd`.
**Preconditions:** **Server restart** before capstone run resets quest progress, reward delivery store, inventory, and skill progression.
Full capstone checklist: [`docs/manual-qa/NEO-132.md`](../docs/manual-qa/NEO-132.md).
## End-to-end onboarding quest loop (NEO-123) ## End-to-end onboarding quest loop (NEO-123)
Epic 7 Slice 1 capstone — complete all four prototype quests (three onboarding intros + operator chain) **in Godot** without Bruno. Epic 7 Slice 1 capstone — complete all four prototype quests (three onboarding intros + operator chain) **in Godot** without Bruno.

View File

@ -13,6 +13,7 @@ const SCHEMA_VERSION := 1
var _http: Node var _http: Node
var _busy: bool = false var _busy: bool = false
var _sync_pending: bool = false
func _ready() -> void: func _ready() -> void:
@ -29,7 +30,12 @@ func _ready() -> void:
func request_sync_from_server() -> void: func request_sync_from_server() -> void:
if _busy: if _busy:
_sync_pending = true
return return
_start_sync_request()
func _start_sync_request() -> void:
_busy = true _busy = true
var url := "%s/game/players/%s/inventory" % [_base_root(), _player_path_segment()] var url := "%s/game/players/%s/inventory" % [_base_root(), _player_path_segment()]
var err: Error = _http.request(url) var err: Error = _http.request(url)
@ -38,6 +44,14 @@ func request_sync_from_server() -> void:
push_warning("InventoryClient: %s" % reason) push_warning("InventoryClient: %s" % reason)
_busy = false _busy = false
inventory_sync_failed.emit(reason) inventory_sync_failed.emit(reason)
_try_flush_pending_sync()
func _try_flush_pending_sync() -> void:
if not _sync_pending or _busy:
return
_sync_pending = false
_start_sync_request()
func _base_root() -> String: func _base_root() -> String:
@ -66,16 +80,19 @@ func _on_request_completed(
var reason := "HTTP failed (result=%s)" % result var reason := "HTTP failed (result=%s)" % result
push_warning("InventoryClient: %s" % reason) push_warning("InventoryClient: %s" % reason)
inventory_sync_failed.emit(reason) inventory_sync_failed.emit(reason)
_try_flush_pending_sync()
return return
if response_code == 404: if response_code == 404:
var reason404 := "HTTP 404 (player unknown)" var reason404 := "HTTP 404 (player unknown)"
push_warning("InventoryClient: %s" % reason404) push_warning("InventoryClient: %s" % reason404)
inventory_sync_failed.emit(reason404) inventory_sync_failed.emit(reason404)
_try_flush_pending_sync()
return return
if response_code < 200 or response_code >= 300: if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code var reason_code := "HTTP %s" % response_code
push_warning("InventoryClient: %s" % reason_code) push_warning("InventoryClient: %s" % reason_code)
inventory_sync_failed.emit(reason_code) inventory_sync_failed.emit(reason_code)
_try_flush_pending_sync()
return return
var text := body.get_string_from_utf8() var text := body.get_string_from_utf8()
var snapshot: Variant = parse_inventory_json(text) var snapshot: Variant = parse_inventory_json(text)
@ -83,5 +100,7 @@ func _on_request_completed(
var reason_json := "non-JSON body or schemaVersion mismatch" var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("InventoryClient: %s" % reason_json) push_warning("InventoryClient: %s" % reason_json)
inventory_sync_failed.emit(reason_json) inventory_sync_failed.emit(reason_json)
_try_flush_pending_sync()
return return
inventory_received.emit(snapshot as Dictionary) inventory_received.emit(snapshot as Dictionary)
_try_flush_pending_sync()

View File

@ -865,7 +865,9 @@ func _setup_quest_progress_sync() -> void:
_quest_accept_feedback_label, _quest_accept_feedback_label,
Callable(self, "_apply_authority_http_config_to_client"), Callable(self, "_apply_authority_http_config_to_client"),
_quest_reward_delivery_label, _quest_reward_delivery_label,
_item_defs_client _item_defs_client,
_inventory_client,
_skill_progression_client
) )

View File

@ -2,6 +2,9 @@ extends Node
## NEO-122: quest progress + accept HUD wiring (extracted from main.gd). ## NEO-122: quest progress + accept HUD wiring (extracted from main.gd).
## NEO-131: quest completion reward label on in-session completed transition. ## NEO-131: quest completion reward label on in-session completed transition.
## NEO-132: emits quest_completion_reward_transition for economy HUD refresh.
signal quest_completion_reward_transition(quest_id: String, summary: Dictionary)
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd") const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
const GATHER_QUEST_ID := "prototype_quest_gather_intro" const GATHER_QUEST_ID := "prototype_quest_gather_intro"
@ -23,6 +26,8 @@ var _previous_status_by_quest: Dictionary = {}
var _quest_was_active_in_session: Dictionary = {} var _quest_was_active_in_session: Dictionary = {}
var _last_reward_quest_id: String = "" var _last_reward_quest_id: String = ""
var _last_reward_summary: Dictionary = {} var _last_reward_summary: Dictionary = {}
var _inventory_client: Node = null
var _skill_progression_client: Node = null
func setup( func setup(
@ -32,11 +37,15 @@ func setup(
accept_label: Label, accept_label: Label,
apply_http_config: Callable, apply_http_config: Callable,
reward_label: Label = null, reward_label: Label = null,
item_defs_client: Node = null item_defs_client: Node = null,
inventory_client: Node = null,
skill_progression_client: Node = null
) -> void: ) -> void:
_progress_client = progress_client _progress_client = progress_client
_defs_client = defs_client _defs_client = defs_client
_item_defs_client = item_defs_client _item_defs_client = item_defs_client
_inventory_client = inventory_client
_skill_progression_client = skill_progression_client
_progress_label = progress_label _progress_label = progress_label
_accept_label = accept_label _accept_label = accept_label
_reward_label = reward_label _reward_label = reward_label
@ -205,6 +214,22 @@ func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void:
_last_reward_summary = (summary_variant as Dictionary).duplicate(true) _last_reward_summary = (summary_variant as Dictionary).duplicate(true)
else: else:
_last_reward_summary = {} _last_reward_summary = {}
if not _last_reward_quest_id.is_empty() and not _last_reward_summary.is_empty():
quest_completion_reward_transition.emit(_last_reward_quest_id, _last_reward_summary)
_refresh_economy_hud()
func _refresh_economy_hud() -> void:
if (
is_instance_valid(_inventory_client)
and _inventory_client.has_method("request_sync_from_server")
):
_inventory_client.call("request_sync_from_server")
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 _quest_ids_in_render_order(snapshot: Dictionary) -> Array: func _quest_ids_in_render_order(snapshot: Dictionary) -> Array:

View File

@ -13,6 +13,7 @@ const SCHEMA_VERSION := 1
var _http: Node var _http: Node
var _busy: bool = false var _busy: bool = false
var _sync_pending: bool = false
func _ready() -> void: func _ready() -> void:
@ -29,7 +30,12 @@ func _ready() -> void:
func request_sync_from_server() -> void: func request_sync_from_server() -> void:
if _busy: if _busy:
_sync_pending = true
return return
_start_sync_request()
func _start_sync_request() -> void:
_busy = true _busy = true
var url := "%s/game/players/%s/skill-progression" % [_base_root(), _player_path_segment()] var url := "%s/game/players/%s/skill-progression" % [_base_root(), _player_path_segment()]
var err: Error = _http.request(url) var err: Error = _http.request(url)
@ -38,6 +44,14 @@ func request_sync_from_server() -> void:
push_warning("SkillProgressionClient: %s" % reason) push_warning("SkillProgressionClient: %s" % reason)
_busy = false _busy = false
progression_sync_failed.emit(reason) progression_sync_failed.emit(reason)
_try_flush_pending_sync()
func _try_flush_pending_sync() -> void:
if not _sync_pending or _busy:
return
_sync_pending = false
_start_sync_request()
func skill_row(skill_id: String, snapshot: Dictionary = {}) -> Dictionary: func skill_row(skill_id: String, snapshot: Dictionary = {}) -> Dictionary:
@ -82,16 +96,19 @@ func _on_request_completed(
var reason := "HTTP failed (result=%s)" % result var reason := "HTTP failed (result=%s)" % result
push_warning("SkillProgressionClient: %s" % reason) push_warning("SkillProgressionClient: %s" % reason)
progression_sync_failed.emit(reason) progression_sync_failed.emit(reason)
_try_flush_pending_sync()
return return
if response_code == 404: if response_code == 404:
var reason404 := "HTTP 404 (player unknown)" var reason404 := "HTTP 404 (player unknown)"
push_warning("SkillProgressionClient: %s" % reason404) push_warning("SkillProgressionClient: %s" % reason404)
progression_sync_failed.emit(reason404) progression_sync_failed.emit(reason404)
_try_flush_pending_sync()
return return
if response_code < 200 or response_code >= 300: if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code var reason_code := "HTTP %s" % response_code
push_warning("SkillProgressionClient: %s" % reason_code) push_warning("SkillProgressionClient: %s" % reason_code)
progression_sync_failed.emit(reason_code) progression_sync_failed.emit(reason_code)
_try_flush_pending_sync()
return return
var text := body.get_string_from_utf8() var text := body.get_string_from_utf8()
var snapshot: Variant = parse_progression_json(text) var snapshot: Variant = parse_progression_json(text)
@ -99,5 +116,7 @@ func _on_request_completed(
var reason_json := "non-JSON body or schemaVersion mismatch" var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("SkillProgressionClient: %s" % reason_json) push_warning("SkillProgressionClient: %s" % reason_json)
progression_sync_failed.emit(reason_json) progression_sync_failed.emit(reason_json)
_try_flush_pending_sync()
return return
progression_received.emit(snapshot as Dictionary) progression_received.emit(snapshot as Dictionary)
_try_flush_pending_sync()

View File

@ -36,6 +36,33 @@ class MockHttpTransport:
return OK return OK
class PendingSyncHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var request_count: int = 0
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:
request_count += 1
return OK
func complete_pending() -> void:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
static func _empty_inventory_json() -> String: static func _empty_inventory_json() -> String:
var bag_parts: PackedStringArray = PackedStringArray() var bag_parts: PackedStringArray = PackedStringArray()
for i in range(24): for i in range(24):
@ -111,3 +138,17 @@ func test_malformed_json_emits_inventory_sync_failed() -> void:
c.call("request_sync_from_server") c.call("request_sync_from_server")
# Assert # Assert
await assert_signal(c).is_emitted("inventory_sync_failed", any()) await assert_signal(c).is_emitted("inventory_sync_failed", any())
func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void:
# Arrange
var transport := PendingSyncHttpTransport.new()
transport.body_json = _empty_inventory_json()
var c := _make_client(transport)
# Act
c.call("request_sync_from_server")
c.call("request_sync_from_server")
# Assert
assert_that(transport.request_count).is_equal(1)
transport.complete_pending()
assert_that(transport.request_count).is_equal(2)

View File

@ -1,10 +1,13 @@
extends GdUnitTestSuite extends GdUnitTestSuite
## NEO-131: quest completion reward label HUD tests. ## NEO-131: quest completion reward label HUD tests.
## NEO-132: quest_completion_reward_transition signal for economy refresh.
const QuestHudController := preload("res://scripts/quest_hud_controller.gd") const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd") const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd")
const ItemDefsClient := preload("res://scripts/item_definitions_client.gd") const ItemDefsClient := preload("res://scripts/item_definitions_client.gd")
const InventoryClient := preload("res://scripts/inventory_client.gd")
const SkillProgressionClient := preload("res://scripts/skill_progression_client.gd")
const OPERATOR_CHAIN_QUEST_ID := "prototype_quest_operator_chain" const OPERATOR_CHAIN_QUEST_ID := "prototype_quest_operator_chain"
@ -37,6 +40,38 @@ class StubItemDefsClient:
return item_id return item_id
class MockEconomySyncClient:
extends Node
var sync_count: int = 0
func request_sync_from_server() -> void:
sync_count += 1
class PendingSyncHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var request_count: int = 0
var body_json: String = ""
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
request_count += 1
return OK
func complete_pending() -> void:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer()
)
func _make_defs_client(transport: Node) -> Node: func _make_defs_client(transport: Node) -> Node:
var c: Node = QuestDefinitionsClient.new() var c: Node = QuestDefinitionsClient.new()
c.set("injected_http", transport) c.set("injected_http", transport)
@ -272,3 +307,98 @@ func test_sync_failure_shows_progress_error_and_idle_reward_label() -> void:
# Assert # Assert
assert_str(progress_label.text).contains("error — HTTP 503") assert_str(progress_label.text).contains("error — HTTP 503")
assert_str(reward_label.text).is_equal("Quest rewards:\n") assert_str(reward_label.text).is_equal("Quest rewards:\n")
func test_completion_transition_emits_economy_refresh_signal() -> void:
# Arrange
var controller: Node = QuestHudController.new()
auto_free(controller)
add_child(controller)
monitor_signals(controller)
# Act
controller.call("_on_progress_received", _active_gather_snapshot())
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Assert
await assert_signal(controller).is_emitted("quest_completion_reward_transition", any(), any())
func test_completion_transition_invokes_economy_refresh_callback() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var inventory_client := MockEconomySyncClient.new()
var skill_client := MockEconomySyncClient.new()
auto_free(controller)
auto_free(inventory_client)
auto_free(skill_client)
add_child(controller)
add_child(inventory_client)
add_child(skill_client)
controller.set("_inventory_client", inventory_client)
controller.set("_skill_progression_client", skill_client)
# Act
controller.call("_on_progress_received", _active_gather_snapshot())
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Assert
assert_int(inventory_client.sync_count).is_equal(1)
assert_int(skill_client.sync_count).is_equal(1)
func test_boot_completed_does_not_emit_economy_refresh_signal() -> void:
# Arrange
var controller: Node = QuestHudController.new()
auto_free(controller)
add_child(controller)
monitor_signals(controller)
# Act
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Assert
await (assert_signal(controller).wait_until(200).is_not_emitted(
"quest_completion_reward_transition"
))
func test_completion_transition_queues_economy_refresh_while_clients_busy() -> void:
# Arrange
var inv_transport := PendingSyncHttpTransport.new()
var bag_parts: PackedStringArray = PackedStringArray()
for i in range(24):
bag_parts.append('{"slotIndex":%d,"quantity":0}' % i)
inv_transport.body_json = (
(
'{"schemaVersion":1,"playerId":"dev-local-1","bagSlots":[%s],'
+ '"equipmentSlots":[{"slotIndex":0,"quantity":0}]}'
)
% ", ".join(bag_parts)
)
var skill_transport := PendingSyncHttpTransport.new()
skill_transport.body_json = (
'{"schemaVersion":1,"playerId":"dev-local-1","skills":'
+ '[{"id":"salvage","xp":0,"level":1}]}'
)
var inventory_client: Node = InventoryClient.new()
var skill_client: Node = SkillProgressionClient.new()
inventory_client.set("injected_http", inv_transport)
skill_client.set("injected_http", skill_transport)
var controller: Node = QuestHudController.new()
auto_free(inv_transport)
auto_free(skill_transport)
auto_free(inventory_client)
auto_free(skill_client)
auto_free(controller)
add_child(inventory_client)
add_child(skill_client)
add_child(controller)
controller.set("_inventory_client", inventory_client)
controller.set("_skill_progression_client", skill_client)
inventory_client.call("request_sync_from_server")
skill_client.call("request_sync_from_server")
# Act
controller.call("_on_progress_received", _active_gather_snapshot())
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Assert
assert_int(inv_transport.request_count).is_equal(1)
assert_int(skill_transport.request_count).is_equal(1)
inv_transport.complete_pending()
skill_transport.complete_pending()
assert_int(inv_transport.request_count).is_equal(2)
assert_int(skill_transport.request_count).is_equal(2)

View File

@ -36,6 +36,33 @@ class MockHttpTransport:
return OK return OK
class PendingSyncHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var request_count: int = 0
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:
request_count += 1
return OK
func complete_pending() -> void:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
static func _trio_progression_json() -> String: static func _trio_progression_json() -> String:
return ( return (
'{"schemaVersion":1,"playerId":"dev-local-1","skills":[' '{"schemaVersion":1,"playerId":"dev-local-1","skills":['
@ -95,3 +122,17 @@ func test_http_404_emits_progression_sync_failed() -> void:
c.call("request_sync_from_server") c.call("request_sync_from_server")
# Assert # Assert
await assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)") await assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")
func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void:
# Arrange
var transport := PendingSyncHttpTransport.new()
transport.body_json = _trio_progression_json()
var c := _make_client(transport)
# Act
c.call("request_sync_from_server")
c.call("request_sync_from_server")
# Assert
assert_that(transport.request_count).is_equal(1)
transport.complete_pending()
assert_that(transport.request_count).is_equal(2)

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M2 | | **Module ID** | E7.M2 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Prototype | | **Stage target** | Prototype |
| **Status** | Planned — Slice 2 backlog [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md): **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** `MissionRewardSkillXpGrant` for bundle skill XP (`mission_reward`). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory. | | **Status** | Ready — Slice 2 backlog [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md): **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** `MissionRewardSkillXpGrant` for bundle skill XP (`mission_reward`). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory. |
## Purpose ## Purpose
@ -79,3 +79,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**NEO-130 (E7M2-07 telemetry hooks):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** — first-time **`TryRecord`** success and future **`UnlockGrant`** apply region respectively; no production ingest. See [NEO-130 implementation plan](../../plans/NEO-130-implementation-plan.md); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **NEO-130 (E7M2-07 telemetry hooks):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** — first-time **`TryRecord`** success and future **`UnlockGrant`** apply region respectively; no production ingest. See [NEO-130 implementation plan](../../plans/NEO-130-implementation-plan.md); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130).
**NEO-131 (E7M2-08 client HUD):** Godot **`QuestRewardDeliveryLabel`** + **`quest_hud_controller.gd`** transition detection paints **`completionRewardSummary`** grant lines when a quest newly becomes **`completed`** in-session (parse via **`quest_progress_client.gd`**). See [NEO-131 implementation plan](../../plans/NEO-131-implementation-plan.md); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **NEO-131 (E7M2-08 client HUD):** Godot **`QuestRewardDeliveryLabel`** + **`quest_hud_controller.gd`** transition detection paints **`completionRewardSummary`** grant lines when a quest newly becomes **`completed`** in-session (parse via **`quest_progress_client.gd`**). See [NEO-131 implementation plan](../../plans/NEO-131-implementation-plan.md); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131).
**NEO-132 (E7M2-09 client capstone):** playable four-quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); economy HUD refresh on completion transition via **`quest_completion_reward_transition`** signal. Epic 7 Slice 2 client capstone complete.

File diff suppressed because one or more lines are too long

View File

@ -110,9 +110,9 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Ready | | E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Ready |
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**; label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. **E7M1-12 / NEO-123** client capstone landed — [`NEO-123` manual QA](../../manual-qa/NEO-123.md), [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123), plan [NEO-123](../../plans/NEO-123-implementation-plan.md). Epic 7 Slice 1 client capstone complete. **E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**; label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. **E7M1-12 / NEO-123** client capstone landed — [`NEO-123` manual QA](../../manual-qa/NEO-123.md), [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123), plan [NEO-123](../../plans/NEO-123-implementation-plan.md). Epic 7 Slice 1 client capstone complete.
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned | | E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Ready |
**E7.M2 note:** Epic 7 **Slice 2** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. See [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2_RewardAndUnlockRouter.md](E7_M2_RewardAndUnlockRouter.md). Upstream: E7.M1 **Ready**, E2.M2 grant stack ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)), E3.M3 inventory **Ready**. Client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). Register row stays **Planned** until capstone lands. **E7.M2 note:** Epic 7 **Slice 2** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**; label **`E7.M2`**. See [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2_RewardAndUnlockRouter.md](E7_M2_RewardAndUnlockRouter.md). Upstream: E7.M1 **Ready**, E2.M2 grant stack ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)), E3.M3 inventory **Ready**. **E7M2-09 / NEO-132** client capstone landed — [`NEO-132` manual QA](../../manual-qa/NEO-132.md), [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132), plan [NEO-132](../../plans/NEO-132-implementation-plan.md). Epic 7 Slice 2 client capstone complete.
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned | | E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned | | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |

View File

@ -134,6 +134,7 @@ Adjust gathers/crafts if bag counts differ; counters and craft denies are author
- Chain terminal step is **`inventory_has_item`** — no token consume in Slice 1. - Chain terminal step is **`inventory_has_item`** — no token consume in Slice 1.
- **`POST /game/__dev/combat-targets-fixture`** is **out of scope** — clears encounter progress unsuitable for idempotency ([server README — dev combat-target fixture](../../server/README.md#dev-combat-target-fixture-brunomanual-qa)). - **`POST /game/__dev/combat-targets-fixture`** is **out of scope** — clears encounter progress unsuitable for idempotency ([server README — dev combat-target fixture](../../server/README.md#dev-combat-target-fixture-brunomanual-qa)).
- Epic 7 Slice 1 AC: 3 onboarding + 1 chain complete without duped completion ([epic_07 Definition of Done](../decomposition/epics/epic_07_quest_faction.md#epic-7-definition-of-done)). - Epic 7 Slice 1 AC: 3 onboarding + 1 chain complete without duped completion ([epic_07 Definition of Done](../decomposition/epics/epic_07_quest_faction.md#epic-7-definition-of-done)).
- **Slice 2 reward verification:** quest completion bundles + economy idempotency — [NEO-132 manual QA](NEO-132.md) (supersedes for reward delivery AC).
## Acceptance ## Acceptance

View File

@ -39,3 +39,5 @@
- [NEO-122](NEO-122.md) — quest progress + accept HUD unchanged except new label below accept feedback. - [NEO-122](NEO-122.md) — quest progress + accept HUD unchanged except new label below accept feedback.
- [NEO-110](NEO-110.md) — encounter loot label still independent from quest completion bundles. - [NEO-110](NEO-110.md) — encounter loot label still independent from quest completion bundles.
**Full-flow capstone:** [NEO-132](NEO-132.md) extends this checklist with economy HUD verification across all four quest completion bundles.

View File

@ -0,0 +1,118 @@
# NEO-132 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-132 |
| Title | E7M2-09: Playable quest reward delivery capstone (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-132/e7m2-09-playable-quest-reward-delivery-capstone-godot |
| Plan | `docs/plans/NEO-132-implementation-plan.md` |
| Branch | `NEO-132-playable-quest-reward-delivery-capstone` |
## Preconditions
- **Fresh dev player:** stop any running server, then start a new instance so in-memory quest progress, **`IRewardDeliveryStore`**, inventory, skill progression, encounter progress, NPC HP, and resource-node depletion reset.
- **No Bruno/curl** for this checklist — accept uses **Q** / **Shift+Q**; gameplay uses **R**, **Tab** + **1**, and Economy HUD **Craft** buttons only.
- NEO-124NEO-131 reward spine and NEO-123 onboarding capstone landed on `main`.
- **`Economy HUD`** toggle **on** for inventory + skill verification.
## Quest completion reward freeze (E7M2-01)
| Quest id | Display name | Bundle grants | Expected **`QuestRewardDeliveryLabel`** |
|----------|--------------|---------------|----------------------------------------|
| `prototype_quest_gather_intro` | Intro: Salvage Run | `salvage` **+25** XP | `Salvage +25 XP` |
| `prototype_quest_combat_intro` | Intro: Clear the Pocket | `salvage` **+25** XP | `Salvage +25 XP` |
| `prototype_quest_refine_intro` | Intro: Refine Stock | `refine` **+25** XP | `Refine +25 XP` |
| `prototype_quest_operator_chain` | Operator Chain | `survey_drone_kit` ×1 + `salvage` **+50** XP | `Survey Drone Kit ×1` + `Salvage +50 XP` |
**Encounter loot (unchanged — E5.M3):** combat pocket still grants **`scrap_metal_bulk` ×10** + **`contract_handoff_token` ×1** on **`EncounterCompleteLabel`**, not quest bundles.
## Skill XP verification method
Record **`SkillProgressionLabel`** salvage/refine **`xp`** values **immediately before** each quest completion transition and **after** the HUD refreshes. Assert the **delta** matches the freeze table (+25 or +50 for salvage; +25 for refine). Gather/craft activity XP may also change totals — deltas per quest completion are authoritative.
## Expected HUD progression (extends NEO-123)
| Phase | `QuestProgressLabel` | `QuestRewardDeliveryLabel` | Economy HUD |
|-------|----------------------|----------------------------|-------------|
| Boot | All four **`not started`** | `Quest rewards:` / `—` | salvage/refine xp **0** (fresh server) |
| Gather intro **`completed`** | Salvage Run **`completed`** | `Intro: Salvage Run` + `Salvage +25 XP` | salvage xp **+25** delta |
| Combat intro **`completed`** | Clear the Pocket **`completed`** | `Intro: Clear the Pocket` + `Salvage +25 XP` | salvage xp **+25** delta |
| Refine intro **`completed`** | Refine Stock **`completed`** | `Intro: Refine Stock` + `Refine +25 XP` | refine xp **+25** delta |
| Operator chain **`completed`** | Operator Chain **`completed`** | `Survey Drone Kit ×1` + `Salvage +50 XP` | bag has **`survey_drone_kit`**; salvage xp **+50** delta |
| Godot restart | All four still **`completed`** | `—` (no transition replay) | counts **unchanged** |
| Duplicate accepts | Unchanged | Unchanged | counts **unchanged** |
## Checklist
Follow [NEO-123](NEO-123.md) for accept order, anchors, material math, and quest steps **115**. **Extend** each completion with reward + economy checks below.
### Boot
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm four quests **`not started`**, **`QuestRewardDeliveryLabel`** shows **`Quest rewards:`** / **`—`**, and **`SkillProgressionLabel`** shows salvage/refine xp **0** (fresh server).
### Intro: Salvage Run (gather + skill XP reward)
3. Press **Q**; gather at **gamma** **(12, 0)** once (+3 scrap) until **`Intro: Salvage Run: completed`**.
4. Verify **`QuestRewardDeliveryLabel`**: `Intro: Salvage Run` + **`Salvage +25 XP`**.
5. Verify **`SkillProgressionLabel`**: salvage xp increased by **25** vs value recorded before completion (activity gather XP may also apply — quest bundle delta is **+25**).
### Intro: Clear the Pocket (combat + skill XP reward)
6. Press **Shift+Q**; defeat all three prototype NPCs (**Tab** + **1**, any order).
7. Verify **`EncounterCompleteLabel`** still lists encounter loot (**`scrap_metal_bulk ×10`**, **`contract_handoff_token ×1`**) — separate from quest bundle.
8. Verify **`Intro: Clear the Pocket: completed`** on **`QuestProgressLabel`**.
9. Verify **`QuestRewardDeliveryLabel`**: `Intro: Clear the Pocket` + **`Salvage +25 XP`**.
10. Verify **`SkillProgressionLabel`**: salvage xp **+25** delta since step 5.
### Intro: Refine Stock (craft + skill XP reward)
11. Press **Shift+Q**; craft **`refine_scrap_standard`** when **≥5** scrap in bag.
12. Verify **`Intro: Refine Stock: completed`**.
13. Verify **`QuestRewardDeliveryLabel`**: `Intro: Refine Stock` + **`Refine +25 XP`**.
14. Verify **`SkillProgressionLabel`**: refine xp **+25** delta since step 10.
### Operator Chain (item + skill XP reward)
15. Press **Shift+Q**; complete chain steps 14 per [NEO-123](NEO-123.md) (gather ×5 → refine → stim → token held).
16. Verify **`Operator Chain: completed`**.
17. Verify **`QuestRewardDeliveryLabel`**: **`Survey Drone Kit ×1`** (or raw id fallback) + **`Salvage +50 XP`**.
18. Verify **`InventoryLabel`** lists **`survey_drone_kit`** (display name when item defs loaded).
19. Verify **`SkillProgressionLabel`**: salvage xp **+50** delta since step 14.
### Capstone snapshot (record for idempotency)
20. **Record** final state:
- **`QuestProgressLabel`**: all four **`completed`**
- **`SkillProgressionLabel`**: salvage + refine xp totals
- **`InventoryLabel`**: includes **`survey_drone_kit`**; note scrap/plate/token/stim counts
### Idempotency (no duplicate reward delivery)
21. **Godot restart:** stop Godot (**Shift+F5**); **F5** again **without** stopping the server.
22. Verify all four quests still **`completed`**; **`QuestRewardDeliveryLabel`** is **`—`** (transition-only).
23. Verify **`SkillProgressionLabel`** and **`InventoryLabel`** counts **match step 20** exactly.
24. Press **Q** on gather intro. Verify **`Quest accept: denied — already_completed`** (or **`already_active`**).
25. Press **Shift+Q**. Verify **`Quest accept: no eligible quest`** (or equivalent deny).
26. Verify economy HUD counts **still match step 20** — no duplicate skill XP or items.
### Regression
27. Component reward HUD: [NEO-131 manual QA](NEO-131.md).
28. Slice 1 quest chain: [NEO-123 manual QA](NEO-123.md).
29. Encounter loot independence: [NEO-110 manual QA](NEO-110.md).
## Notes
- **Shift+Q catalog order** matches NEO-123 — combat intro before refine intro after gather completes.
- Quest completion bundles are **idempotent** server-side (`{playerId}:quest_complete:{questId}`); Godot restart must not replay grant copy on **`QuestRewardDeliveryLabel`** (NEO-131 transition-only).
- Economy HUD auto-refreshes on quest completion transition (NEO-132); no manual **I** required for reward verification.
- Epic 7 Slice 2 AC: idempotent reward delivery; replays cannot double-claim ([epic_07 Slice 2](../decomposition/epics/epic_07_quest_faction.md#slice-2---reward-and-unlock-routing)).
## Acceptance
- [ ] Steps 126 completable in one session without Bruno/curl.
- [ ] All four quest completion bundles visible on **`QuestRewardDeliveryLabel`** during in-session transitions.
- [ ] Skill XP deltas and **`survey_drone_kit`** in bag match freeze table.
- [ ] Godot restart + duplicate accepts leave economy counts unchanged (no double-claim).
- [ ] Epic 7 Slice 2 AC satisfied in Godot.

View File

@ -296,6 +296,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
- [ ] Epic 7 Slice 2 AC: idempotent reward delivery; replays cannot double-claim. - [ ] Epic 7 Slice 2 AC: idempotent reward delivery; replays cannot double-claim.
- [ ] Re-read [epic_07 Slice 2 AC](../decomposition/epics/epic_07_quest_faction.md#slice-2---reward-and-unlock-routing). - [ ] Re-read [epic_07 Slice 2 AC](../decomposition/epics/epic_07_quest_faction.md#slice-2---reward-and-unlock-routing).
**Landed ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** capstone manual QA [`NEO-132`](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../client/README.md#end-to-end-quest-reward-loop-neo-132); economy HUD refresh on quest completion transition; plan [NEO-132](../../plans/NEO-132-implementation-plan.md).
**Client counterpart:** this story **is** the Slice 2 client capstone. **Client counterpart:** this story **is** the Slice 2 client capstone.
--- ---

View File

@ -0,0 +1,165 @@
# NEO-132 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-132 |
| **Title** | E7M2-09: Playable quest reward delivery capstone (Godot) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-132/e7m2-09-playable-quest-reward-delivery-capstone-godot |
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-09** |
| **Branch** | `NEO-132-playable-quest-reward-delivery-capstone` |
| **Client deps** | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) quest completion reward HUD (**Done** on `main`); [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) Slice 1 capstone flow; [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) quest progress HUD |
| **Server deps** | [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)[NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) reward spine (**landed on `main`**) — catalog, delivery store, router, quest-state wiring, HTTP **`completionRewardSummary`** |
| **Pattern** | Capstone integration — [NEO-123](NEO-123-implementation-plan.md) docs + manual QA primary; minimal gap-fill only if capstone QA fails on `main` |
| **Blocked by** | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) (**Done**) |
| **Server counterpart** | NEO-124NEO-130 server spine; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Implementation scope** | Docs-only vs client code? | **Docs + manual QA primary**; fix integration gaps only if capstone QA fails on `main` — [NEO-123](NEO-123-implementation-plan.md) precedent; NEO-131 deferred economy HUD refresh to this capstone. | **Adopted** — docs-primary |
| **Capstone path** | Extend full NEO-123 vs shorter reward-focused path? | **Extend full NEO-123 four-quest flow** — add **`QuestRewardDeliveryLabel`** + economy HUD verification at each completion; covers skill-only, item+skill, and idempotency across all grant kinds in the freeze table. | **Adopted** — full NEO-123 extend |
**Additional defaults (no kickoff question — settled by backlog / landed code):**
- **Session baseline:** fresh **server restart** before Godot **F5** — resets in-memory quest progress, **`IRewardDeliveryStore`**, inventory, skill progression, encounter state, and resource-node depletion; zero Bruno/curl in main checklist.
- **Economy HUD:** toggle **on** for inventory + skill verification (NEO-75 / NEO-123 precedent).
- **Reward HUD:** transition-only paint per NEO-131 — boot with already-completed quests does not replay grant copy.
- **Encounter loot vs quest bundles:** combat pocket **`scrap_metal_bulk` ×10** + **`contract_handoff_token` ×1** remain on **`EncounterCompleteLabel`** (NEO-110); quest completion bundles are skill XP and/or **`survey_drone_kit`** on **`QuestRewardDeliveryLabel`** (E7M2 freeze).
- **Server changes:** none unless capstone QA exposes an authoritative bug (unlikely — covered by server reward router tests).
## Goal, scope, and out-of-scope
**Goal:** Prove Epic 7 Slice 2 acceptance **in Godot**: quest completion grants items + skill XP **once**; idempotent on replay; satisfies [epic_07 Slice 2 AC](../decomposition/epics/epic_07_quest_faction.md#slice-2---reward-and-unlock-routing) and [Definition of Done](../decomposition/epics/epic_07_quest_faction.md#epic-7-definition-of-done).
**In scope (from Linear + [E7M2-09](E7M2-prototype-backlog.md#e7m2-09--playable-quest-reward-delivery-capstone-godot)):**
- **`docs/manual-qa/NEO-132.md`**: numbered **single-session** capstone — extend [NEO-123](../manual-qa/NEO-123.md) four-quest flow; at each quest **`completed`** transition verify **`QuestRewardDeliveryLabel`**, **`SkillProgressionLabel`**, and **`InventoryLabel`** (where applicable); **idempotency** via Godot restart + duplicate accept keys with economy counts unchanged.
- **`client/README.md`**: **End-to-end quest reward loop (NEO-132)** section — integration flow table; cross-links NEO-124NEO-131 and Slice 1 capstone.
- **Module alignment** (on story completion): update [E7_M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) status, [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M2 row, [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M2 note, and [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md) E7M2-09 checkboxes; mark Epic 7 Slice 2 client capstone complete.
- **Integration fixes only if capstone QA fails** on current `main` wiring (economy HUD stale after quest completion, reward label gap, idempotency breach, etc.).
**Out of scope (from Linear + backlog):**
- Encounter loot migration; faction reputation; quest VFX art.
- Bruno-only verification as prototype-complete proof.
- New server HTTP routes or reward router logic beyond NEO-127/NEO-128.
- Skill-definitions client; unlock/flag grant kinds (deferred pre-production).
## Acceptance criteria checklist
- [ ] Human completes **`docs/manual-qa/NEO-132.md`** with server + client.
- [ ] Epic 7 Slice 2 AC: idempotent reward delivery; replays cannot double-claim.
- [ ] Re-read [epic_07 Slice 2 AC](../decomposition/epics/epic_07_quest_faction.md#slice-2---reward-and-unlock-routing).
## Implementation reconciliation (shipped)
- **`docs/manual-qa/NEO-132.md`** — four-quest capstone extending NEO-123 with reward HUD + economy verification + idempotency steps.
- **`client/README.md`** — **End-to-end quest reward loop (NEO-132)** section with flow table and cross-links.
- **`client/scripts/quest_hud_controller.gd`** — **`quest_completion_reward_transition`** signal + **`_refresh_economy_hud()`** on in-session completion (inventory + skill GET).
- **`client/scripts/main.gd`** — passes **`InventoryClient`** + **`SkillProgressionClient`** into quest HUD **`setup`**.
- **`docs/manual-qa/NEO-123.md`**, **`docs/manual-qa/NEO-131.md`** — cross-links to NEO-132 capstone.
- **`docs/plans/E7M2-prototype-backlog.md`**, **`E7_M2_RewardAndUnlockRouter.md`**, **`documentation_and_implementation_alignment.md`**, **`module_dependency_register.md`** — E7M2-09 landed; E7.M2 **Ready**; Epic 7 Slice 2 client capstone complete.
## Technical approach
### 1. Reward freeze reference (E7M2-01)
| Quest id | Quest completion bundle (server) | Expected **`QuestRewardDeliveryLabel`** | Economy HUD asserts |
|----------|----------------------------------|----------------------------------------|---------------------|
| `prototype_quest_gather_intro` | `salvage` **+25** XP | `Salvage +25 XP` | **`SkillProgressionLabel`**: salvage XP increases by **25** |
| `prototype_quest_combat_intro` | `salvage` **+25** XP | `Salvage +25 XP` | salvage XP **+25** (cumulative **50** after gather + combat) |
| `prototype_quest_refine_intro` | `refine` **+25** XP | `Refine +25 XP` | refine XP **+25** |
| `prototype_quest_operator_chain` | `survey_drone_kit` ×1 + `salvage` **+50** XP | `Survey Drone Kit ×1` + `Salvage +50 XP` | bag contains **`survey_drone_kit`**; salvage XP **+50** (cumulative **100** if all four completed in one session) |
**Idempotency key (server):** `{playerId}:quest_complete:{questId}` — replay **`completed`** status skips router apply ([NEO-128](NEO-128-implementation-plan.md)).
### 2. Capstone manual QA script (`docs/manual-qa/NEO-132.md`)
**Preconditions:** Same as [NEO-123](../manual-qa/NEO-123.md) — fresh server restart, Godot gameplay only, **`Economy HUD`** on.
**Flow:** Inherit NEO-123 accept order (**Q** gather → **Shift+Q** combat → **Shift+Q** refine → **Shift+Q** operator chain), material math, and combat/gather anchors. **Extend** each completion step with:
1. **`QuestRewardDeliveryLabel`** — grant lines on in-session transition only (NEO-131).
2. **`SkillProgressionLabel`** — record salvage/refine XP after each completion (note baseline at boot).
3. **`InventoryLabel`** — after operator chain completion, assert **`survey_drone_kit`** present (display name when item defs loaded).
**Idempotency (reward-specific):**
1. After all four quests **`completed`**, record final **`SkillProgressionLabel`** salvage/refine totals and bag rows (including **`survey_drone_kit`**).
2. **Godot restart** (server still running): reward label **`—`**; quest statuses still **`completed`**; economy counts **unchanged** from step 1.
3. **Duplicate accepts:** **Q** / **Shift+Q** deny per NEO-123; economy counts still unchanged.
**Regression pointers:** [NEO-131](../manual-qa/NEO-131.md) component reward HUD, [NEO-123](../manual-qa/NEO-123.md) Slice 1 capstone, [NEO-110](../manual-qa/NEO-110.md) encounter loot independence.
### 3. Client README (`client/README.md`)
Add **End-to-end quest reward loop (NEO-132)** section after [Quest completion reward HUD (NEO-131)](../../client/README.md#quest-completion-reward-hud-neo-131), mirroring NEO-123 capstone section:
- One-line Epic 7 Slice 2 capstone goal.
- Flow table: fresh restart → four-quest NEO-123 path → per-completion reward HUD + economy verification → idempotency.
- Cross-links: NEO-124 catalog freeze, NEO-126 delivery store, NEO-127 router, NEO-129 HTTP summary, NEO-131 HUD, NEO-123 onboarding flow.
- Pointer to **`docs/manual-qa/NEO-132.md`**.
### 4. Module / backlog alignment (on land)
When capstone QA passes:
- [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md) — E7M2-09 AC checkboxes.
- [E7_M2_RewardAndUnlockRouter.md](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) — module status **Ready**; Slice 2 client capstone note.
- [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) — E7.M2 row: NEO-132 landed; Epic 7 Slice 2 client capstone complete.
- [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) — E7.M2 **Ready** when capstone lands.
### 5. Integration fixes (conditional)
Run capstone QA on `main` (or branch with NEO-131 merged). If any step fails:
| Symptom | Likely fix surface |
|---------|-------------------|
| **`SkillProgressionLabel`** stale after combat intro complete | `main.gd` — add skill GET refresh on quest-progress GET when combat quest completes (defeat path currently refreshes encounter + quest only) |
| **`InventoryLabel`** missing **`survey_drone_kit`** after operator chain | `main.gd` — refresh inventory on quest completion transition (operator chain completes via token check without new gather/craft) |
| Reward label wrong/missing | `quest_hud_controller.gd` — unlikely if NEO-131 GdUnit passes |
| Economy counts increase on Godot restart + replay | Server bug — file issue; client displays GET only |
| Duplicate delivery on replay | Server **`IRewardDeliveryStore`** / router — file issue |
Document any fix in **Implementation reconciliation** and add regression note to manual QA.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-132-implementation-plan.md` | This plan. |
| `docs/manual-qa/NEO-132.md` | Single-session four-quest capstone with reward HUD + economy verification + idempotency steps. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/README.md` | **End-to-end quest reward loop (NEO-132)** section with flow table and cross-links. |
| `docs/manual-qa/NEO-123.md` | Cross-link to NEO-132 Slice 2 capstone; note reward/economy verification defers to NEO-132. |
| `docs/manual-qa/NEO-131.md` | Cross-link to NEO-132 capstone as full-flow superset. |
| `docs/plans/E7M2-prototype-backlog.md` | E7M2-09 AC checkboxes when capstone lands. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | Module status **Ready** + capstone landed note on completion. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 row: NEO-132 landed; Slice 2 complete. |
| `docs/decomposition/modules/module_dependency_register.md` | E7.M2 **Ready** when capstone lands. |
**Conditional (only if capstone QA fails):** `client/scripts/main.gd` (economy HUD refresh on quest completion transition) — minimal gap-fill; document paths in reconciliation.
## Tests
| Test file | What it covers |
|-----------|----------------|
| `client/test/quest_reward_hud_test.gd` | **Add:** `quest_completion_reward_transition` emitted on in-session completion; not emitted on boot-completed snapshot. |
| *(server unchanged)* | Reward spine covered by **`RewardRouterOperationsTests`**, **`QuestStateOperationsTests`**, **`QuestProgressApiTests`**. Capstone proof is **`docs/manual-qa/NEO-132.md`** human QA. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Economy HUD auto-refresh gaps** | Add **`quest_completion_reward_transition`** signal + **`main.gd`** inventory/skill refresh — combat and operator-chain paths lacked auto-refresh. | **adopted** |
| **NEO-123 vs NEO-132 relationship** | NEO-132 **supersedes** as Slice 2 capstone for reward idempotency; keep NEO-123 as Slice 1 regression. | **adopted** |
| **Manual I key in QA script** | Prefer asserting auto-refresh after each completion; use **I** only as documented workaround if docs-primary QA passes with manual refresh and auto-fix is out of scope. | **deferred** |
| **Cumulative skill XP math** | Script records **delta** per completion from **`SkillProgressionLabel`**, not absolute totals — avoids coupling to unrelated XP sources. | **adopted** |
| **Docs-only fails capstone** | Economy refresh hook landed proactively; capstone QA confirms end-to-end. | **adopted** |