Compare commits

..

No commits in common. "bc2e9d60458565c0739bbd3dd6bfcc61b092f943" and "4e5d92b6c81d36612a934d6ac154985aae0ba597" have entirely different histories.

47 changed files with 132 additions and 2787 deletions

View File

@ -1,133 +0,0 @@
meta {
name: GET quest progress after gather intro complete
type: http
seq: 8
}
docs {
NEO-129: accept gather intro, complete via alpha gathers, GET quest-progress asserts completionRewardSummary; second GET unchanged.
Tolerates craft spine (seq 5) partial progress and node_depleted on alpha when quest already completed.
}
script:pre-request {
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const headers = { "Content-Type": "application/json" };
const gatherQuestId = "prototype_quest_gather_intro";
const alphaNodeId = "prototype_resource_node_alpha";
function gatherRow(body) {
return body.quests.find((x) => x.questId === gatherQuestId);
}
async function getQuestProgress() {
const response = await axios.get(
`${baseUrl}/game/players/${playerId}/quest-progress`,
{ validateStatus: () => true },
);
if (response.status !== 200) {
throw new Error(`quest-progress GET failed: ${response.status}`);
}
return response.data;
}
async function tryGatherAlpha() {
const interact = await axios.post(
`${baseUrl}/game/players/${playerId}/interact`,
{
schemaVersion: 1,
interactableId: alphaNodeId,
},
{ headers, validateStatus: () => true },
);
if (interact.status !== 200) {
throw new Error(`gather interact HTTP ${interact.status}: ${JSON.stringify(interact.data)}`);
}
if (interact.data?.allowed === true) {
return;
}
if (interact.data?.reasonCode === "node_depleted") {
return;
}
throw new Error(`gather interact denied: ${JSON.stringify(interact.data)}`);
}
await axios.post(
`${baseUrl}/game/players/${playerId}/quests/${gatherQuestId}/accept`,
{ schemaVersion: 1 },
{ headers, validateStatus: () => true },
);
let progress = await getQuestProgress();
let row = gatherRow(progress);
if (!row || row.status === "not_started") {
await axios.post(
`${baseUrl}/game/players/${playerId}/move`,
{
schemaVersion: 1,
target: { x: 10, y: 0.9, z: -6 },
},
{ headers },
);
}
for (let attempt = 0; attempt < 8; attempt += 1) {
progress = await getQuestProgress();
row = gatherRow(progress);
if (row?.status === "completed" && row.completionRewardSummary) {
break;
}
await tryGatherAlpha();
}
progress = await getQuestProgress();
row = gatherRow(progress);
if (!row || row.status !== "completed" || !row.completionRewardSummary) {
throw new Error(
`expected gather intro completed with summary, got ${JSON.stringify(row)}`,
);
}
bru.setVar(
"firstGatherCompletionRewardSummary",
JSON.stringify(row.completionRewardSummary),
);
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/quest-progress
body: none
auth: none
}
tests {
test("gather intro row is completed with completionRewardSummary", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
const row = body.quests.find((x) => x.questId === "prototype_quest_gather_intro");
expect(row).to.be.an("object");
expect(row.status).to.equal("completed");
expect(row.completionRewardSummary).to.be.an("object");
expect(row.completionRewardSummary.itemGrants).to.eql([]);
expect(row.completionRewardSummary.skillXpGrants).to.eql([
{ skillId: "salvage", amount: 25 },
]);
});
test("second GET completionRewardSummary matches first GET", function () {
const body = res.getBody();
const row = body.quests.find((x) => x.questId === "prototype_quest_gather_intro");
const firstSummary = JSON.parse(bru.getVar("firstGatherCompletionRewardSummary"));
expect(row.completionRewardSummary).to.eql(firstSummary);
});
test("active and not_started rows omit completionRewardSummary", function () {
const body = res.getBody();
for (const row of body.quests) {
if (row.status === "not_started" || row.status === "active") {
expect(row.completionRewardSummary).to.equal(undefined);
}
}
});
}

View File

@ -39,7 +39,6 @@ tests {
expect(row.currentStepIndex).to.equal(0);
expect(row.objectiveCounters).to.eql({});
expect(row.completedAt).to.equal(undefined);
expect(row.completionRewardSummary).to.equal(undefined);
}
});
}

View File

@ -1,28 +0,0 @@
meta {
name: GET health (quest complete reward wiring NEO-128)
type: http
seq: 3
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-128 wires RewardRouterOperations into QuestStateOperations.TryMarkComplete (deliver-then-mark).
No new HTTP routes — quest-progress GET/accept POST signatures gained reward-router DI only.
Use this request to confirm the host started after wiring; verify bundle delivery via dotnet test
(QuestStateOperationsTests, QuestObjectiveWiringTests).
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

View File

@ -239,18 +239,6 @@ Full checklist: [`docs/manual-qa/NEO-110.md`](../docs/manual-qa/NEO-110.md).
Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md).
## Quest completion reward HUD (NEO-131)
- **`GET /game/players/{id}/quest-progress`** — completed rows may include **`completionRewardSummary`** with **`itemGrants`** + **`skillXpGrants`** (NEO-129); see [server README — Per-player quest progress](../server/README.md#per-player-quest-progress-neo-119).
- **Scripts:** `scripts/quest_progress_client.gd` (**`completion_reward_summary`** helper), `scripts/quest_hud_controller.gd` (transition detection + reward label render).
- **HUD:**
- **`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`**).
- **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).
- **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).
## 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.

View File

@ -1260,17 +1260,6 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Quest accept: — (Q gather / Shift+Q next)"
[node name="QuestRewardDeliveryLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000027]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.78, 0.95, 0.82, 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 rewards:
—"
[node name="NpcStateLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000020]
layout_mode = 2
size_flags_horizontal = 3

View File

@ -136,7 +136,6 @@ var _dev_pulse_bootstrap_attempted: bool = false
@onready var _encounter_complete_label: Label = _hud_root.get_node("EncounterCompleteLabel")
@onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel")
@onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel")
@onready var _quest_reward_delivery_label: Label = _hud_root.get_node("QuestRewardDeliveryLabel")
@onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel")
@onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel")
@onready var _cooldown_slots_label: Label = _hud_root.get_node("CooldownSlotsLabel")
@ -863,9 +862,7 @@ func _setup_quest_progress_sync() -> void:
_quest_defs_client,
_quest_progress_label,
_quest_accept_feedback_label,
Callable(self, "_apply_authority_http_config_to_client"),
_quest_reward_delivery_label,
_item_defs_client
Callable(self, "_apply_authority_http_config_to_client")
)

View File

@ -1,28 +1,20 @@
extends Node
## NEO-122: quest progress + accept HUD wiring (extracted from main.gd).
## NEO-131: quest completion reward label on in-session completed transition.
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
const GATHER_QUEST_ID := "prototype_quest_gather_intro"
const ACCEPT_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)"
const ACCEPT_SENDING_HINT := "Quest accept: sending…"
const REWARD_HEADER := "Quest rewards:"
var _progress_client: Node = null
var _defs_client: Node = null
var _item_defs_client: Node = null
var _progress_label: Label = null
var _accept_label: Label = null
var _reward_label: Label = null
var _last_snapshot: Dictionary = {}
var _progress_error: String = ""
var _defs_error: String = ""
var _accept_quest_patch: Dictionary = {}
var _previous_status_by_quest: Dictionary = {}
var _quest_was_active_in_session: Dictionary = {}
var _last_reward_quest_id: String = ""
var _last_reward_summary: Dictionary = {}
func setup(
@ -30,16 +22,12 @@ func setup(
defs_client: Node,
progress_label: Label,
accept_label: Label,
apply_http_config: Callable,
reward_label: Label = null,
item_defs_client: Node = null
apply_http_config: Callable
) -> void:
_progress_client = progress_client
_defs_client = defs_client
_item_defs_client = item_defs_client
_progress_label = progress_label
_accept_label = accept_label
_reward_label = reward_label
if apply_http_config.is_valid():
apply_http_config.call(progress_client)
apply_http_config.call(defs_client)
@ -59,11 +47,8 @@ func setup(
_defs_client.connect(
"definitions_sync_failed", Callable(self, "_on_definitions_sync_failed")
)
if is_instance_valid(_item_defs_client) and _item_defs_client.has_signal("definitions_ready"):
_item_defs_client.connect("definitions_ready", Callable(self, "_on_item_definitions_ready"))
_render_progress_label()
_render_accept_feedback_label()
_render_reward_label()
if _defs_client.has_method("request_sync_from_server"):
_defs_client.call("request_sync_from_server")
request_progress_refresh()
@ -93,11 +78,9 @@ func try_accept_key_input(event: InputEvent) -> bool:
func _on_progress_received(snapshot: Dictionary) -> void:
_progress_error = ""
_detect_and_apply_completion_transitions(snapshot)
_last_snapshot = snapshot.duplicate(true)
_reapply_accept_quest_patch_if_needed()
_render_progress_label()
_render_reward_label()
func _on_sync_failed(reason: String) -> void:
@ -107,12 +90,10 @@ func _on_sync_failed(reason: String) -> void:
elif _last_snapshot.is_empty():
_last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot({}, _accept_quest_patch)
_render_progress_label()
_render_reward_label()
func _on_accept_result_received(quest_id: String, result: Dictionary) -> void:
if bool(result.get("accepted", false)):
_mark_quest_active_in_session(quest_id)
_render_accept_feedback_label("Quest accept: %s accepted" % _display_name(quest_id))
var quest_variant: Variant = result.get("quest", null)
if quest_variant is Dictionary:
@ -139,8 +120,6 @@ func _on_definitions_ready(_quests: Array) -> void:
_defs_error = ""
if not _last_snapshot.is_empty():
_render_progress_label()
if not _last_reward_quest_id.is_empty():
_render_reward_label()
func _on_definitions_sync_failed(reason: String) -> void:
@ -148,87 +127,6 @@ func _on_definitions_sync_failed(reason: String) -> void:
_render_progress_label()
func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
if not _last_reward_quest_id.is_empty():
_render_reward_label()
func _mark_quest_active_in_session(quest_id: String) -> void:
var qid := quest_id.strip_edges()
if not qid.is_empty():
_quest_was_active_in_session[qid] = true
func _detect_and_apply_completion_transitions(snapshot: Dictionary) -> void:
var boot_seed := _previous_status_by_quest.is_empty()
var transitions: Array = []
var quests_variant: Variant = snapshot.get("quests", null)
if quests_variant is Array:
for row_variant in quests_variant as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var qid := str(row.get("questId", "")).strip_edges()
if qid.is_empty():
continue
var new_status := str(row.get("status", "not_started"))
if new_status == "active":
_mark_quest_active_in_session(qid)
var prev_status := str(_previous_status_by_quest.get(qid, "not_started"))
var can_detect := not boot_seed or bool(_quest_was_active_in_session.get(qid, false))
if can_detect and prev_status != "completed" and new_status == "completed":
var summary := QuestProgressClient.completion_reward_summary(qid, snapshot)
if not summary.is_empty():
transitions.append({"questId": qid, "summary": summary})
_previous_status_by_quest[qid] = new_status
if transitions.is_empty():
return
var ordered_ids: Array = _quest_ids_in_render_order(snapshot)
var best_idx := -1
var best_transition: Dictionary = {}
for transition_variant in transitions:
if not transition_variant is Dictionary:
continue
var transition: Dictionary = transition_variant
var quest_id := str(transition.get("questId", ""))
var idx: int = ordered_ids.find(quest_id)
if idx < 0:
idx = ordered_ids.size()
if idx >= best_idx:
best_idx = idx
best_transition = transition
if best_transition.is_empty():
return
_last_reward_quest_id = str(best_transition.get("questId", ""))
var summary_variant: Variant = best_transition.get("summary", null)
if summary_variant is Dictionary:
_last_reward_summary = (summary_variant as Dictionary).duplicate(true)
else:
_last_reward_summary = {}
func _quest_ids_in_render_order(snapshot: Dictionary) -> Array:
var ordered: Array = []
var defs: Array = _defs_snapshot()
if not defs.is_empty():
for def_variant in defs:
if not def_variant is Dictionary:
continue
var qid := str((def_variant as Dictionary).get("id", "")).strip_edges()
if not qid.is_empty():
ordered.append(qid)
return ordered
var quests_variant: Variant = snapshot.get("quests", null)
if quests_variant is Array:
for row_variant in quests_variant as Array:
if not row_variant is Dictionary:
continue
var qid := str((row_variant as Dictionary).get("questId", "")).strip_edges()
if not qid.is_empty():
ordered.append(qid)
return ordered
func _render_progress_label() -> void:
if not is_instance_valid(_progress_label):
return
@ -271,65 +169,6 @@ func _render_progress_label() -> void:
_progress_label.text = "\n".join(lines)
func _render_reward_label() -> void:
if not is_instance_valid(_reward_label):
return
if not _progress_error.is_empty():
_reward_label.text = "%s\n" % REWARD_HEADER
return
if _last_reward_quest_id.is_empty() or _last_reward_summary.is_empty():
_reward_label.text = "%s\n" % REWARD_HEADER
return
var lines: PackedStringArray = [REWARD_HEADER, _display_name(_last_reward_quest_id)]
lines.append_array(_format_reward_summary_lines(_last_reward_summary))
_reward_label.text = "\n".join(lines)
func _format_reward_summary_lines(summary: Dictionary) -> PackedStringArray:
var lines: PackedStringArray = PackedStringArray()
var items_variant: Variant = summary.get("itemGrants", null)
if items_variant is Array:
for grant_variant in items_variant as Array:
if not grant_variant is Dictionary:
continue
var grant: Dictionary = grant_variant
var item_id := str(grant.get("itemId", "")).strip_edges()
var qty: int = int(grant.get("quantity", 0))
if item_id.is_empty() or qty <= 0:
continue
var label := _item_display_name(item_id)
lines.append(" %s ×%d" % [label, qty])
var skills_variant: Variant = summary.get("skillXpGrants", null)
if skills_variant is Array:
for grant_variant in skills_variant as Array:
if not grant_variant is Dictionary:
continue
var grant: Dictionary = grant_variant
var skill_id := str(grant.get("skillId", "")).strip_edges()
var amount: int = int(grant.get("amount", 0))
if skill_id.is_empty() or amount <= 0:
continue
lines.append(" %s +%d XP" % [_title_case_token(skill_id), amount])
if lines.is_empty():
lines.append(" (no grants)")
return lines
func _item_display_name(item_id: String) -> String:
if not is_instance_valid(_item_defs_client):
return item_id
if not _item_defs_client.has_method("display_name_for"):
return item_id
return str(_item_defs_client.call("display_name_for", item_id))
static func _title_case_token(token: String) -> String:
var trimmed := token.strip_edges()
if trimmed.is_empty():
return trimmed
return trimmed.substr(0, 1).to_upper() + trimmed.substr(1)
func _format_status_line(_quest_id: String, display_name: String, row: Dictionary) -> String:
var status: String = str(row.get("status", "not_started"))
match status:

View File

@ -1,7 +1,6 @@
extends Node
## NEO-122: HTTP client for quest-progress GET (NEO-119) and quest accept POST (NEO-120).
## NEO-131: rows may include optional completionRewardSummary (NEO-129) on completed quests.
signal quest_progress_received(snapshot: Dictionary)
signal quest_sync_failed(reason: String)
@ -78,23 +77,6 @@ func request_accept(quest_id: String) -> bool:
return true
static func completion_reward_summary(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:
continue
var summary_variant: Variant = row.get("completionRewardSummary", null)
if summary_variant is Dictionary:
return (summary_variant as Dictionary).duplicate(true)
return {}
return {}
func quest_row(quest_id: String, snapshot: Dictionary = {}) -> Dictionary:
var quests: Variant = snapshot.get("quests", null)
if quests == null or not quests is Array:

View File

@ -75,17 +75,6 @@ static func _completed_json() -> String:
)
static func _completed_with_reward_summary_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",'
+ '"completionRewardSummary":{"itemGrants":[],"skillXpGrants":'
+ '[{"skillId":"salvage","amount":25}]}}]}'
)
static func _accept_success_json() -> String:
return (
'{"schemaVersion":1,"accepted":true,"quest":'
@ -209,27 +198,6 @@ func test_parse_active_row_has_objective_counters() -> void:
assert_that(int((counters as Dictionary).get("gather_intro_obj_scrap", 0))).is_equal(2)
func test_parse_completed_row_has_completion_reward_summary() -> void:
# Arrange
var json := _completed_with_reward_summary_json()
# Act
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var summary: Dictionary = QuestProgressClient.completion_reward_summary(
PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary
)
var items: Variant = summary.get("itemGrants", null)
assert_that(items is Array).is_true()
assert_that((items as Array).size()).is_equal(0)
var skills: Variant = summary.get("skillXpGrants", null)
assert_that(skills is Array).is_true()
assert_that((skills as Array).size()).is_equal(1)
var skill_row: Dictionary = (skills as Array)[0] as Dictionary
assert_that(str(skill_row.get("skillId", ""))).is_equal("salvage")
assert_that(int(skill_row.get("amount", 0))).is_equal(25)
func test_parse_completed_row_has_completed_at() -> void:
# Arrange
var json := _completed_json()

View File

@ -1,274 +0,0 @@
extends GdUnitTestSuite
## NEO-131: quest completion reward label HUD tests.
const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd")
const ItemDefsClient := preload("res://scripts/item_definitions_client.gd")
const OPERATOR_CHAIN_QUEST_ID := "prototype_quest_operator_chain"
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var body_json: String = ""
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
request_completed.emit(
HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer()
)
return OK
class StubItemDefsClient:
extends Node
func display_name_for(item_id: String) -> String:
if item_id == "survey_drone_kit":
return "Survey Drone Kit"
return item_id
func _make_defs_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 _gather_defs_json() -> String:
return (
'{"schemaVersion":1,"quests":['
+ '{"id":"prototype_quest_gather_intro","displayName":"Intro: Salvage Run",'
+ '"prerequisiteQuestIds":[],"steps":[]}]}'
)
func _active_gather_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests":
[
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "active",
"currentStepIndex": 0,
"objectiveCounters": {"gather_intro_obj_scrap": 2},
}
],
}
func _completed_gather_with_summary_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests":
[
{
"questId": QuestHudController.GATHER_QUEST_ID,
"status": "completed",
"currentStepIndex": 0,
"objectiveCounters": {},
"completedAt": "2026-06-07T12:00:00Z",
"completionRewardSummary":
{
"itemGrants": [],
"skillXpGrants": [{"skillId": "salvage", "amount": 25}],
},
}
],
}
func _active_operator_chain_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests":
[
{
"questId": OPERATOR_CHAIN_QUEST_ID,
"status": "active",
"currentStepIndex": 3,
"objectiveCounters": {},
}
],
}
func _completed_operator_chain_with_summary_snapshot() -> Dictionary:
return {
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests":
[
{
"questId": OPERATOR_CHAIN_QUEST_ID,
"status": "completed",
"currentStepIndex": 3,
"objectiveCounters": {},
"completedAt": "2026-06-07T14:00:00Z",
"completionRewardSummary":
{
"itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}],
"skillXpGrants": [{"skillId": "salvage", "amount": 50}],
},
}
],
}
func _survey_drone_item_defs_json() -> String:
return (
'{"schemaVersion":1,"items":[{"id":"survey_drone_kit",'
+ '"displayName":"Survey Drone Kit","inventorySlotKind":"bag"}]}'
)
func test_completion_transition_paints_reward_label_with_skill_xp() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var reward_label := Label.new()
auto_free(controller)
auto_free(reward_label)
add_child(controller)
controller.set("_reward_label", reward_label)
# Act
controller.call("_on_progress_received", _active_gather_snapshot())
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Assert
assert_str(reward_label.text).contains("Quest rewards:")
assert_str(reward_label.text).contains("Salvage +25 XP")
func test_first_success_paints_reward_when_quest_was_active_in_session() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var reward_label := Label.new()
auto_free(controller)
auto_free(reward_label)
add_child(controller)
controller.set("_reward_label", reward_label)
controller.call("_mark_quest_active_in_session", QuestHudController.GATHER_QUEST_ID)
# Act
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Assert
assert_str(reward_label.text).contains("Salvage +25 XP")
func test_boot_completed_snapshot_does_not_paint_reward_label() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var reward_label := Label.new()
auto_free(controller)
auto_free(reward_label)
add_child(controller)
controller.set("_reward_label", reward_label)
# Act
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Assert
assert_str(reward_label.text).is_equal("Quest rewards:\n")
func test_completion_transition_paints_item_grant_with_display_name() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var reward_label := Label.new()
var item_defs := StubItemDefsClient.new()
auto_free(controller)
auto_free(reward_label)
auto_free(item_defs)
add_child(controller)
add_child(item_defs)
controller.set("_reward_label", reward_label)
controller.set("_item_defs_client", item_defs)
# Act
controller.call("_on_progress_received", _active_operator_chain_snapshot())
controller.call("_on_progress_received", _completed_operator_chain_with_summary_snapshot())
# Assert
assert_str(reward_label.text).contains("Survey Drone Kit ×1")
assert_str(reward_label.text).contains("Salvage +50 XP")
func test_definitions_ready_repaints_reward_label_with_display_name() -> void:
# Arrange
var defs_transport := MockHttpTransport.new()
defs_transport.body_json = _gather_defs_json()
var defs_client: Node = _make_defs_client(defs_transport)
var controller: Node = QuestHudController.new()
var reward_label := Label.new()
auto_free(controller)
auto_free(reward_label)
add_child(controller)
controller.set("_defs_client", defs_client)
controller.set("_reward_label", reward_label)
controller.call("_on_progress_received", _active_gather_snapshot())
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
assert_str(reward_label.text).contains(QuestHudController.GATHER_QUEST_ID)
# Act
defs_client.call("request_sync_from_server")
controller.call("_on_definitions_ready", [])
# Assert
assert_str(reward_label.text).contains("Intro: Salvage Run")
assert_str(reward_label.text).contains("Salvage +25 XP")
assert_str(reward_label.text).not_contains("prototype_quest_gather_intro")
func test_item_definitions_ready_repaints_reward_item_display_name() -> void:
# Arrange
var item_transport := MockHttpTransport.new()
item_transport.body_json = _survey_drone_item_defs_json()
var item_defs: Node = ItemDefsClient.new()
item_defs.set("injected_http", item_transport)
auto_free(item_transport)
auto_free(item_defs)
add_child(item_defs)
await get_tree().process_frame
var controller: Node = QuestHudController.new()
var reward_label := Label.new()
auto_free(controller)
auto_free(reward_label)
add_child(controller)
controller.set("_reward_label", reward_label)
controller.set("_item_defs_client", item_defs)
controller.call("_on_progress_received", _active_operator_chain_snapshot())
controller.call("_on_progress_received", _completed_operator_chain_with_summary_snapshot())
assert_str(reward_label.text).contains("survey_drone_kit")
# Act
item_defs.call("request_sync_from_server")
controller.call("_on_item_definitions_ready", {})
# Assert
assert_str(reward_label.text).contains("Survey Drone Kit ×1")
assert_str(reward_label.text).not_contains("survey_drone_kit ×1")
func test_sync_failure_shows_progress_error_and_idle_reward_label() -> void:
# Arrange
var controller: Node = QuestHudController.new()
var progress_label := Label.new()
var reward_label := Label.new()
auto_free(controller)
auto_free(progress_label)
auto_free(reward_label)
add_child(controller)
controller.set("_progress_label", progress_label)
controller.set("_reward_label", reward_label)
controller.set("_last_snapshot", _active_gather_snapshot())
controller.call("_on_progress_received", _completed_gather_with_summary_snapshot())
# Act
controller.call("_on_sync_failed", "HTTP 503")
# Assert
assert_str(progress_label.text).contains("error — HTTP 503")
assert_str(reward_label.text).is_equal("Quest rewards:\n")

View File

@ -1 +0,0 @@
uid://d4n7q1rewardhudtest01

View File

@ -71,11 +71,3 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**NEO-126 (E7M2-03 delivery store):** Quest completion reward delivery uses **`IRewardDeliveryStore`** + **`InMemoryRewardDeliveryStore`** in `server/NeonSprawl.Server/Game/Rewards/` — idempotent **`RewardDeliveryEvent`** per player+quest (idempotency key **`{playerId}:quest_complete:{questId}`**; grant snapshots for item + skill XP rows). Quest-state wiring lands in NEO-128; HTTP **`completionRewardSummary`** in NEO-129. See [NEO-126 implementation plan](../../plans/NEO-126-implementation-plan.md); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126).
**NEO-127 (E7M2-04 router apply):** **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/` applies **`QuestRewardBundleRow`** item grants via **`PlayerInventoryOperations`** and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** (`mission_reward` only), with compensating item rollback on partial failure, then **`IRewardDeliveryStore.TryRecord`**. See [NEO-127 implementation plan](../../plans/NEO-127-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127).
**NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117).
**NEO-129 (E7M2-06 HTTP read):** **`GET /game/players/{id}/quest-progress`** projects optional **`completionRewardSummary`** on **`completed`** rows from **`IRewardDeliveryStore.TryGet`** — nested **`itemGrants`** + **`skillXpGrants`** mirroring delivery event snapshots; omitted when no delivery record. **`QuestAcceptApi`** embed rows use the same mapper. See [NEO-129 implementation plan](../../plans/NEO-129-implementation-plan.md); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`.
**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).

File diff suppressed because one or more lines are too long

View File

@ -1,33 +0,0 @@
# Manual QA — NEO-130 (reward telemetry hook sites)
Reference: [implementation plan](../plans/NEO-130-implementation-plan.md), [NEO-127 implementation plan](../plans/NEO-127-implementation-plan.md) (reward router), [NEO-128 implementation plan](../plans/NEO-128-implementation-plan.md) (quest-state wiring).
## Preconditions
- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`) if exercising Bruno; not required for code review only.
- This story adds **comments only**; reward delivery behavior matches NEO-127/128.
## Code review check
- [ ] Open `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs`.
- [ ] Confirm **`unlock_granted`** stub comment block after item + skill XP apply loops, before **`RewardDeliveryEvent`** construction.
- [ ] Confirm **`reward_delivery`** comment block immediately after **`deliveryStore.TryRecord`** returns **`true`** (inside the success branch).
- [ ] Confirm **`TODO(E9.M1)`** markers and planned payload fields; **no** new `ILogger` or metrics calls.
- [ ] Confirm **no** duplicate hook comments in **`QuestStateOperations`**, **`QuestAcceptApi`**, or **`QuestProgressApi`**.
- [ ] Open `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs` — xml summary cross-references **`reward_delivery`** hook site.
- [ ] Open [server README — Reward telemetry hooks (NEO-130)](../../server/README.md#reward-telemetry-hooks-neo-130) — NEO-130 subsection present with event table.
## Regression (optional)
```bash
cd server && dotnet test NeonSprawl.sln
```
Expect all tests pass (comments-only change).
## Bruno (optional sanity)
From `bruno/neon-sprawl-server/quest-progress/` against a running server:
- **Get quest progress after gather intro complete****`completionRewardSummary`** unchanged when quest completes via wiring.
- **Health after quest complete reward wiring** — server health unchanged.

View File

@ -1,41 +0,0 @@
# NEO-131 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-131 |
| Title | E7M2-08: Client quest completion reward HUD (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-131/e7m2-08-client-quest-completion-reward-hud-godot |
| Plan | `docs/plans/NEO-131-implementation-plan.md` |
| Branch | `NEO-131-client-quest-completion-reward-hud` |
## Preconditions
- **Fresh dev player:** stop any running server, then start a new instance so in-memory quest progress and delivery store reset.
- **No Bruno/curl** for this checklist — use Godot gameplay only.
- NEO-129 **`completionRewardSummary`** on quest-progress GET landed on `main`.
- NEO-122 quest HUD + NEO-128 reward delivery wiring landed on `main`.
## Expected reward HUD progression
| After | `QuestRewardDeliveryLabel` |
|-------|----------------------------|
| Boot (no completions yet) | `Quest rewards:` / `—` |
| Gather intro completes (3× scrap) | `Intro: Salvage Run` + `Salvage +25 XP` |
| Godot restart (server still running, quests already completed) | `—` (transition-only; no replay) |
| Operator chain completes | item line (`Survey Drone Kit ×1` when item defs loaded) + `Salvage +50 XP` |
| Quest-progress GET fails (server stopped) | `QuestProgressLabel` shows `sync error — …`; reward label `—` |
## Checklist
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm **`QuestRewardDeliveryLabel`** shows **`Quest rewards:`** / **`—`** below **`QuestAcceptFeedbackLabel`** in **`HudRootScroll`**.
3. Press **Q** to accept gather intro. Gather scrap (**R** on resource node ×3) until **`Intro: Salvage Run: completed`** on **`QuestProgressLabel`**.
4. Verify **`QuestRewardDeliveryLabel`** updates to **`Salvage +25 XP`** (quest display name on the line above when defs loaded).
5. Stop Godot and press **F5** again (server still running). Verify reward label stays **`—`** while progress still shows gather **`completed`** (idempotent boot — no transition replay).
6. Optional — operator chain: follow [NEO-123](NEO-123.md) through **`prototype_quest_operator_chain`** completion. Verify reward label shows **`Survey Drone Kit ×1`** (or raw id fallback) and **`Salvage +50 XP`** on chain completion transition only.
7. Optional: stop server while Godot running; trigger a quest-progress refresh (gather or **Q** accept). Verify **`QuestProgressLabel`** shows **`sync error — …`** and reward label **`—`**.
## Regression spot-check
- [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.

View File

@ -194,8 +194,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**Acceptance criteria**
- [x] Completing a quest via objective wiring delivers bundle once.
- [x] Idempotent complete does not double-grant items or XP.
- [ ] Completing a quest via objective wiring delivers bundle once.
- [ ] Idempotent complete does not double-grant items or XP.
**Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — HUD surfacing (blocked by E7M2-06).
@ -218,8 +218,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**Acceptance criteria**
- [x] Completed quest rows include summary when delivery recorded.
- [x] `not_started` / `active` rows omit summary.
- [ ] Completed quest rows include summary when delivery recorded.
- [ ] `not_started` / `active` rows omit summary.
**Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131).
@ -241,9 +241,7 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**Acceptance criteria**
- [x] Hook sites documented in README; no behavior change beyond comments.
**Landed ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub in **`RewardRouterOperations`**; [server README — Reward telemetry hooks (NEO-130)](../../server/README.md#reward-telemetry-hooks-neo-130); plan [NEO-130](../../plans/NEO-130-implementation-plan.md).
- [ ] Hook sites documented in README; no behavior change beyond comments.
**Client counterpart:** none (infrastructure-only).
@ -267,10 +265,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**Acceptance criteria**
- [x] Completing a quest shows readable reward copy on HUD.
- [x] Failed GET surfaces visible error (NEO-122 pattern).
**Landed ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** **`QuestRewardDeliveryLabel`** + **`quest_hud_controller.gd`** transition detection for **`completionRewardSummary`**; [client README — Quest completion reward HUD (NEO-131)](../../client/README.md#quest-completion-reward-hud-neo-131); plan [NEO-131](../../plans/NEO-131-implementation-plan.md); manual QA [`NEO-131`](../../manual-qa/NEO-131.md).
- [ ] Completing a quest shows readable reward copy on HUD.
- [ ] Failed GET surfaces visible error (NEO-122 pattern).
**Server counterpart:** [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129).

View File

@ -1,165 +0,0 @@
# NEO-128 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-128 |
| **Title** | E7M2-05: Wire router into `QuestStateOperations.TryMarkComplete` |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-128/e7m2-05-wire-router-into-queststateoperationstrymarkcomplete |
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-05** |
| **Branch** | `NEO-128-e7m2-quest-complete-reward-wiring` |
| **Precursor** | [NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) **`Done`** — `RewardRouterOperations.TryDeliverQuestCompletion` |
| **Pattern** | [NEO-105](NEO-105-implementation-plan.md) — deliver side effects before completion mark; [NEO-118](NEO-118-implementation-plan.md) — `QuestObjectiveWiring``TryMarkComplete` on terminal step |
| **Blocks** | [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) (`completionRewardSummary` HTTP), [NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) (telemetry hook comments) |
| **Client counterpart** | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — HUD surfacing (blocked by E7M2-06 / NEO-129) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Delivery vs mark ordering** | When should bundle apply run relative to `TryMarkComplete`? | **Deliver bundle first, then mark complete** — E7M2 “transactional equivalent” to fail-closed; mirrors NEO-105 (grants before completion mark); avoids new `TryRevertCompletion` store API. | **Adopted** — deliver-then-mark |
| **Bruno smoke** | Add Bruno requests for quest-complete reward delivery? | **Skip** — server-internal wiring only; no HTTP DTO changes (NEO-129); automated integration tests cover AC; Bruno optional per backlog because verification is store/inventory/skill assertions, not a new route. | **Adopted** — skip Bruno (functional smoke); **`GET /health`** Bruno added only for pre-commit hook when `*Api.cs` DI signatures changed — bundle asserts remain in `dotnet test`. |
**Additional defaults (no kickoff question — settled by backlog / landed code):**
- **Idempotent quest replay** — when progress is already **`completed`**, return success immediately and **do not** call the router (NEO-121 `quest_complete` hook path unchanged; no double-grant).
- **Deny reason passthrough** — on delivery deny, return **`QuestStateOperationResult`** with **`Success: false`** and **`ReasonCode`** from **`RewardDeliveryResult.ReasonCode`** (`inventory_full`, etc.); quest row stays **`active`**.
- **`QuestStateOperationResult` shape** — unchanged; HTTP projection of grants is NEO-129.
- **Bundle source**`definition.CompletionRewardBundle` from **`IQuestDefinitionRegistry`**; all four prototype quests have bundles (NEO-124/125 CI gates).
- **Wiring best-effort**`QuestObjectiveWiring` continues to ignore `TryMarkComplete` deny envelopes (primary gather/craft/encounter ops unaffected); on inventory-full deny the quest remains active for retry.
## Goal, scope, and out-of-scope
**Goal:** First-time quest completion triggers **`completionRewardBundle`** delivery exactly once via **`RewardRouterOperations`**; idempotent complete replay does not double-grant items or XP.
**In scope (from Linear + [E7M2-05](E7M2-prototype-backlog.md#e7m2-05--wire-router-into-queststateoperationstrymarkcomplete)):**
- Call **`RewardRouterOperations.TryDeliverQuestCompletion`** from **`QuestStateOperations.TryMarkComplete`** on the first-time completion path (before store mark per adopted ordering).
- Skip router on idempotent **`completed`** early return.
- Thread router dependencies through **`QuestObjectiveWiring`** and upstream ops (`GatherOperations`, `CraftOperations`, `EncounterCompletionOperations`, `QuestProgressApi`) so objective-wiring completion exercises the same path.
- Integration tests (AAA): gather-intro wiring → salvage +25 + store record; idempotent `TryMarkComplete` replay → no duplicate XP; inventory-full deny → quest stays active, no store record; operator-chain → item + skill grants.
- `server/README.md` quest-state section update; E7.M2 module alignment anchor.
**Out of scope (from Linear):**
- HTTP **`completionRewardSummary`** ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) E7M2-06).
- E9.M1 telemetry ingest ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) E7M2-07) — existing comment-only hook sites stay.
- Encounter completion path; per-step bundles.
- Bruno smoke (deferred per kickoff).
- Godot client.
## Acceptance criteria checklist
- [x] Completing a quest via objective wiring delivers bundle once.
- [x] Idempotent complete replay does not double-grant items or XP.
## Implementation reconciliation (shipped)
- **Operations:** `QuestStateOperations.TryMarkComplete` — deliver-then-mark via `RewardRouterOperations`; idempotent `completed` skip; deny passthrough from `RewardDeliveryReasonCodes`.
- **Wiring:** `QuestObjectiveWiring` + `GatherOperations` / `CraftOperations` / `EncounterCompletionOperations` / quest HTTP APIs thread reward-router dependencies.
- **Tests:** `QuestStateOperationsTests` — gather + operator-chain bundle delivery, idempotent replay, inventory-full deny; `QuestObjectiveWiringTests` — gather-intro wiring + replay no double-grant; operator-chain wiring happy path (`survey_drone_kit` + salvage +50); wiring inventory-full stay-active on terminal advance.
- **Docs:** `server/README.md`; E7.M2 module anchor + alignment register updated.
## Technical approach
### 1. Extend `TryMarkComplete` (deliver-then-mark)
Add router dependencies to the static signature (mirror **`RewardRouterOperations.TryDeliverQuestCompletion`** injectables):
```csharp
public static QuestStateOperationResult TryMarkComplete(
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
```
**First-time completion flow** (after existing registry / active guards):
1. Load **`definition.CompletionRewardBundle`** from registry (already resolved in guard).
2. **`RewardRouterOperations.TryDeliverQuestCompletion(...)`** — includes store idempotent replay (`already_delivered` success when store hit).
3. If **`!delivery.Success`**, return **`Deny(delivery.ReasonCode!)`** — quest stays **`active`**.
4. **`progressStore.TryMarkComplete(...)`** — on success, emit existing NEO-121 **`quest_complete`** comment hook; return **`Success(snapshot)`**.
5. Preserve existing idempotent / race handling when **`TryMarkComplete`** returns false but progress is already **`completed`** (re-read paths unchanged).
**Idempotent replay** (progress already **`completed`**): existing early return — **no router call**.
### 2. Dependency threading
| Layer | Change |
|-------|--------|
| **`QuestObjectiveWiring`** | Add reward-router parameters to **`TryCompleteStepIfSatisfied`**, **`TryCompleteAllActiveQuests`**, and all public entry points that call them (`TryProcessGatherSuccess`, `TryProcessCraftSuccess`, `TryProcessEncounterCompleteSuccess`, `TryRefreshInventoryProgressForPlayer`). |
| **`QuestStateOperations`** | Pass new deps into **`TryMarkComplete`** from **`TryAccept`** / **`TryAdvanceStep`** wiring calls. |
| **`GatherOperations`**, **`CraftOperations`**, **`EncounterCompletionOperations`** | Extend method signatures + call sites to pass skill/progress/perk/delivery deps into wiring (resolve from existing HTTP/ops injectables same as gather/craft already thread quest stores). |
| **`QuestProgressApi`** | Pass reward deps into **`TryRefreshInventoryProgressForPlayer`**. |
Use **`InMemoryWebApplicationFactory`** DI resolution in tests — no new service registration (stores already in **`Program.cs`**).
### 3. Tests
| Test file | New / extended cases |
|-----------|---------------------|
| **`QuestStateOperationsTests`** | **`TryMarkComplete_ShouldDeliverGatherIntroBundle_WhenFirstCompletion`** — accept + complete → **`IRewardDeliveryStore.TryGet`** true, salvage XP +25. **`TryMarkComplete_ShouldNotDoubleGrant_WhenIdempotentReplay`** — second complete → same store event, XP unchanged. **`TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull`** — fill bag, complete operator-chain quest setup → deny **`inventory_full`**, status **`active`**, no store record. |
| **`QuestObjectiveWiringTests`** | Extend **`GatherIntro_ShouldComplete_WhenScrapGatheredThreeTimes`** (or sibling) — assert salvage XP +25 and delivery store after wiring-driven complete. **`GatherIntro_ShouldNotDoubleGrantXp_WhenCompleteReplayed`** — manual second **`TryMarkComplete`** after wiring complete. |
Reuse **`RewardRouterTestDependencies`**-style resolution or extend existing **`QuestTestDependencies`** / **`QuestWiringTestDependencies`** records with skill + delivery services.
### 4. Docs
- **`server/README.md`** — update **`QuestStateOperations.TryMarkComplete`** section: deliver-then-mark order, idempotent skip, deny passthrough, pointer to **`RewardRouterOperations`**.
- **`documentation_and_implementation_alignment.md`** — E7.M2 row: quest-state wiring (NEO-128).
- **`E7_M2_RewardAndUnlockRouter.md`** — implementation anchor for **`TryMarkComplete`** wiring.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-128-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Wire **`RewardRouterOperations`** into **`TryMarkComplete`**; extend signature; thread deps from accept/advance. |
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Pass reward-router deps through completion wiring chain. |
| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | Thread reward deps into **`TryProcessGatherSuccess`**. |
| `server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs` | Thread reward deps into **`TryProcessCraftSuccess`**. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Thread reward deps into **`TryProcessEncounterCompleteSuccess`**. |
| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Thread reward deps into inventory refresh wiring. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Bundle delivery, idempotent replay, inventory-full deny tests. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Wiring path asserts store + skill XP after gather complete. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | Update **`TryMarkComplete`** call sites with new parameters. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Update **`TryMarkComplete`** / wiring dependency resolution. |
| `server/README.md` | Document quest-complete reward wiring on **`TryMarkComplete`**. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — quest-state wiring (NEO-128). |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | NEO-128 implementation anchor. |
## Tests
| Target | Coverage |
|--------|----------|
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Direct **`TryMarkComplete`**: first completion delivers bundle + records store; idempotent replay no double XP; inventory-full deny leaves quest active without store record. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Gather-intro wiring path delivers salvage +25 once; replay does not double-grant. |
| **Compile-fix only**`QuestAcceptApiTests`, `QuestProgressApiTests` | Updated **`TryMarkComplete`** signatures; existing accept/progress behavior unchanged. |
| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter "QuestStateOperationsTests|QuestObjectiveWiringTests"`; dev server boot unchanged. |
## Open questions / risks
| Question or risk | Agent recommendation | Status |
|------------------|----------------------|--------|
| Delivery succeeds but **`TryMarkComplete`** store write fails while quest still active | Extremely unlikely solo prototype; handle via existing re-read idempotent paths when another caller completed; defer explicit delivery rollback on mark-fail unless concurrency story requires it. | **deferred** |
| Orphan store record if mark never commits | **`TryDeliver`** idempotent replay on retry allows **`already_delivered`** success then mark on next complete attempt — self-healing. | **adopted** |
| Large signature expansion across wiring call graph | Accept explicit parameter threading (NEO-118 precedent); no new DI facade type in this story. | **adopted** |
| **`QuestStateOperationResult` grant projection** | Defer to NEO-129 HTTP read model. | **deferred** |
None beyond the above.

View File

@ -1,187 +0,0 @@
# NEO-129 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-129 |
| **Title** | E7M2-06: Extend GET quest-progress with completionRewardSummary |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-129/e7m2-06-extend-get-quest-progress-with-completionrewardsummary |
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-06** |
| **Branch** | `NEO-129-quest-progress-completion-reward-summary` |
| **Precursor** | [NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) **`Done`** — deliver-then-mark wiring in `QuestStateOperations.TryMarkComplete` |
| **Pattern** | [NEO-108](NEO-108-implementation-plan.md) — optional grant summary on completed rows from event/delivery store; [NEO-119](NEO-119-implementation-plan.md) — quest-progress GET envelope |
| **Blocks** | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — client quest completion reward HUD (E7M2-08) |
| **Client counterpart** | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — parse **`completionRewardSummary`** in Godot; **not in this story** |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **`completionRewardSummary` shape** | How should item + skill XP lines appear in JSON? | **Nested object** `{ itemGrants: [{ itemId, quantity }], skillXpGrants: [{ skillId, amount }] }` — mirrors content **`completionRewardBundle`** and **`RewardDeliveryEvent`** grant snapshots; NEO-131 needs both kinds. | **Adopted** — nested bundle shape |
| **Missing delivery event** | When **`status: completed`** but **`IRewardDeliveryStore.TryGet`** misses? | **Omit** **`completionRewardSummary`** — matches Linear AC (“when delivery recorded”); softer than NEO-108 encounter fail-fast. | **Adopted** — omit when no event |
**Additional defaults (no kickoff question — settled by backlog / landed code):**
- **Source of truth:** **`IRewardDeliveryStore.TryGet(playerId, questId)`** → map **`GrantedItems`** / **`GrantedSkillXp`** only; no second read from quest catalog bundle at HTTP layer.
- **`schemaVersion`:** stay **1** — additive optional field (NEO-108 precedent for **`rewardGrantSummary`**).
- **Row coverage:** **`not_started`** / **`active`** omit **`completionRewardSummary`**; empty grant arrays included when summary is present (e.g. gather intro: **`itemGrants: []`**, **`skillXpGrants: [{ skillId: salvage, amount: 25 }]`**).
- **Mapper consistency:** thread **`IRewardDeliveryStore`** through **`MapQuestProgressRow`** so **`QuestAcceptApi`** embed rows match GET when **`completed`**.
- **Manual QA doc:** skip **`docs/manual-qa/NEO-129.md`** — server-only; Bruno + API integration tests (NEO-108 / NEO-119 precedent).
## Goal, scope, and out-of-scope
**Goal:** Client-readable grant summary on completed quest rows over existing **`GET /game/players/{id}/quest-progress`**.
**In scope (from Linear + [E7M2-06](E7M2-prototype-backlog.md#e7m2-06--extend-get-quest-progress-with-completionrewardsummary)):**
- Extend **`QuestProgressApi`** DTOs + OpenAPI-stable JSON: per completed quest optional **`completionRewardSummary`** (nested **`itemGrants`** + **`skillXpGrants`**).
- Populate from **`IRewardDeliveryStore`** delivery event snapshot.
- Integration tests (AAA): omit on **`not_started`** / **`active`**; present on gather-intro complete + operator-chain complete; idempotent GET unchanged.
- Bruno assertion on gather-intro complete path.
- `server/README.md` quest-progress section update; E7.M2 module alignment anchor.
**Out of scope (from Linear):**
- Godot HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) E7M2-08).
- New top-level route; **`QuestStateOperationResult`** shape changes; telemetry hooks ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)).
- Postgres delivery-store persistence.
## Acceptance criteria checklist
- [x] Completed quest rows include **`completionRewardSummary`** when delivery recorded.
- [x] **`not_started`** / **`active`** rows omit summary.
## Implementation reconciliation (shipped)
- **DTOs:** **`QuestCompletionRewardSummaryJson`** with nested **`itemGrants`** + **`skillXpGrants`**; optional **`completionRewardSummary`** on **`QuestProgressRowJson`**.
- **Projection:** **`QuestProgressApi.BuildSnapshot`** / **`MapQuestProgressRow`** read **`IRewardDeliveryStore.TryGet`**; omit summary when no delivery event (kickoff).
- **Accept:** **`QuestAcceptApi.MapResponse`** threads **`playerId`** + **`deliveryStore`** into row mapper.
- **Tests:** eleven AAA tests in **`QuestProgressApiTests`** — omit on default/active; gather complete salvage **25**; operator chain item + XP; GET-refresh chain terminal summary; omit when completed without delivery record; full-summary idempotent GET replay (gather + operator chain). **`QuestAcceptApiTests`** — null summary on active accept; summary on already-completed deny row.
- **Bruno:** **`Get quest progress after gather intro complete.bru`**; default GET asserts summary omitted on **`not_started`**.
- **Docs:** **`server/README.md`**; E7.M2 module anchor; alignment register; E7M2-06 backlog AC.
## Technical approach
### 1. Wire DTOs (`QuestProgressListDtos.cs`)
Add grant line types and summary wrapper (camelCase JSON, stable with content schemas):
| Type | JSON fields |
|------|-------------|
| **`QuestItemGrantJson`** | **`itemId`**, **`quantity`** |
| **`QuestSkillXpGrantJson`** | **`skillId`**, **`amount`** |
| **`QuestCompletionRewardSummaryJson`** | **`itemGrants`**, **`skillXpGrants`** |
Add optional **`completionRewardSummary`** on **`QuestProgressRowJson`** with **`JsonIgnore(WhenWritingNull)`**.
Reuse item line shape from **`EncounterRewardGrantJson`** field names; skill lines match **`quest-skill-xp-grant.schema.json`**.
### 2. Extend projection (`QuestProgressApi.cs`)
- **`BuildSnapshot`**: accept **`IRewardDeliveryStore`**; pass into per-row mapper.
- **`MapQuestProgressRow(playerId, questId, progressStore, deliveryStore)`** and **`MapQuestProgressRow(playerId, snapshot, deliveryStore)`**:
- On **`completed`**: **`deliveryStore.TryGet`** → when hit, map grant snapshots preserving commit-time order (mirror **`EncounterProgressApi.MapGrantSummary`**).
- When miss: leave **`completionRewardSummary`** null (omitted in JSON).
- On **`active`** / **`not_started`**: no store lookup; summary omitted.
```csharp
private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary(
string playerId,
string questId,
IRewardDeliveryStore deliveryStore)
{
if (!deliveryStore.TryGet(playerId, questId, out var deliveryEvent))
{
return null;
}
return new QuestCompletionRewardSummaryJson
{
ItemGrants = MapItemGrants(deliveryEvent.GrantedItems),
SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp),
};
}
```
### 3. Accept response alignment (`QuestAcceptApi.cs`)
**`MapResponse`**: when embedding **`QuestProgressRowJson`**, call **`MapQuestProgressRow(result.PlayerId, snapshot, deliveryStore)`** — requires threading **`playerId`** + **`deliveryStore`** into **`MapResponse`** from the route handler (already injected).
### 4. Prototype row expectations (frozen E7M2-01)
| Quest id | **`completionRewardSummary`** when completed |
|----------|-----------------------------------------------|
| **`prototype_quest_gather_intro`** | **`itemGrants: []`**, **`skillXpGrants: [{ skillId: salvage, amount: 25 }]`** |
| **`prototype_quest_refine_intro`** | **`itemGrants: []`**, **`skillXpGrants: [{ skillId: refine, amount: 25 }]`** |
| **`prototype_quest_combat_intro`** | **`itemGrants: []`**, **`skillXpGrants: [{ skillId: salvage, amount: 25 }]`** |
| **`prototype_quest_operator_chain`** | **`itemGrants: [{ itemId: survey_drone_kit, quantity: 1 }]`**, **`skillXpGrants: [{ skillId: salvage, amount: 50 }]`** |
### 5. Bruno (`bruno/neon-sprawl-server/quest-progress/`)
**`Get quest progress after gather intro complete.bru`** (new):
- Pre-request: accept gather intro (idempotent) → move near **`prototype_resource_node_alpha`** → interact/gather until objective completes (3× scrap on alpha, same spine as **`QuestProgressApiTests`**).
- GET **`/game/players/{{playerId}}/quest-progress`** → assert gather row **`status: completed`**, **`completionRewardSummary.skillXpGrants`** matches salvage **25**, **`itemGrants`** empty array; second GET unchanged.
### 6. Docs
- **`server/README.md`** — extend quest-progress table with **`completionRewardSummary`** projection source (**`IRewardDeliveryStore`**), sample JSON snippet for gather intro.
- **`E7_M2_RewardAndUnlockRouter.md`** — NEO-129 HTTP anchor.
- **`documentation_and_implementation_alignment.md`** — E7.M2 row note when landed.
- **`E7M2-prototype-backlog.md`** — check E7M2-06 AC when complete.
### Read flow
```mermaid
sequenceDiagram
participant Client
participant API as QuestProgressApi
participant Wiring as QuestObjectiveWiring
participant Store as IPlayerQuestStateStore
participant Delivery as IRewardDeliveryStore
Client->>API: GET /game/players/{id}/quest-progress
API->>Wiring: TryRefreshInventoryProgressForPlayer
API->>Store: per catalog quest TryGetProgress
alt status completed
API->>Delivery: TryGet(playerId, questId)
Delivery-->>API: RewardDeliveryEvent or miss
end
API-->>Client: schemaVersion 1 + quests[] (+ optional completionRewardSummary)
```
## Files to add
| Path | Purpose |
|------|---------|
| `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru` | Gather-intro complete spine → GET asserts **`completionRewardSummary`**. |
| `docs/plans/NEO-129-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs` | Add grant line + **`completionRewardSummary`** wrapper types; optional field on row DTO. |
| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Thread **`IRewardDeliveryStore`** through **`BuildSnapshot`** / **`MapQuestProgressRow`**; map delivery event → summary. |
| `server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs` | Pass **`playerId`** + **`deliveryStore`** into row mapper for embedded quest rows. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Assert summary omitted on default/active; present on gather complete + operator chain; idempotent replay. |
| `server/README.md` | Document **`completionRewardSummary`** on quest-progress GET. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | NEO-129 implementation anchor. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment register when landed. |
| `docs/plans/E7M2-prototype-backlog.md` | Mark E7M2-06 acceptance criteria when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `QuestProgressApiTests.cs` | **Omit:** existing **`not_started`** / **`active`** tests assert **`CompletionRewardSummary`** null. **Gather complete:** extend **`GetQuestProgress_ShouldReturnCompletedGatherIntro_WhenObjectivesSatisfied`** — **`skillXpGrants`** salvage **25**, empty **`itemGrants`**. **Operator chain:** extend chain complete test — **`survey_drone_kit` ×1** + salvage **50**. **Idempotent read:** second GET same summary (new test or assert in gather test). **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Completed without delivery event** | Omit summary per kickoff; no throw. | **adopted** |
| **Empty `itemGrants` in JSON** | Include **`[]`** when summary present — explicit for client parsers (NEO-131). | **adopted** |
| **Bruno shared player state** | Pre-request idempotent accept + gather; tolerate prior collection seq (NEO-119 folder pattern). | **adopted** |
| **NEO-131 display copy** | Defer formatting / HUD timing to client story. | **deferred** |

View File

@ -1,131 +0,0 @@
# NEO-130 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-130 |
| **Title** | E7M2-07: Reward telemetry hook sites (comment-only) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-130/e7m2-07-reward-telemetry-hook-sites-comment-only |
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-07** |
| **Branch** | `NEO-130-reward-telemetry-hooks` |
| **Precursor** | [NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) **`Done`** — `QuestStateOperations.TryMarkComplete`**`RewardRouterOperations`** (deliver-then-mark) |
| **Pattern** | [NEO-121](NEO-121-implementation-plan.md) / [NEO-49](NEO-49-implementation-plan.md) — comment-only engine anchors + **`TODO(E9.M1)`**; engine-only (no duplicate API-layer hooks) |
| **Client counterpart** | none (infrastructure-only per [E7M2-07](E7M2-prototype-backlog.md#e7m2-07--reward-telemetry-hook-sites-comment-only)) |
## Kickoff clarifications
**No clarifications needed.** [E7M2-07](E7M2-prototype-backlog.md#e7m2-07--reward-telemetry-hook-sites-comment-only) backlog, Linear AC, landed router + quest wiring (NEO-127/128), and comment-only telemetry precedents (NEO-49/121) settle scope:
- **Runtime behavior:** comments-only + **`TODO(E9.M1)`** — no production ingest, no **`ILogger`** (NEO-49/121 precedent).
- **Hook anchor:** **`RewardRouterOperations.TryDeliverQuestCompletion`** only — **`QuestStateOperations`** and HTTP quest routes delegate here; no duplicate hooks at quest-state or API layers (NEO-121 engine-only precedent).
- **`reward_delivery` timing:** after **`deliveryStore.TryRecord`** returns **`true`** on first-time delivery (fresh grant + store commit). **Not** on early **`TryGet`** idempotent replay (**`already_delivered`**), deny paths, race rollback, or **`SuccessFromEvent`** after a lost **`TryRecord`** race.
- **`unlock_granted` stub:** comment-only anchor for future **`UnlockGrant`** apply from bundle rows — prototype Slice 2 bundles are item + skill XP only ([E7M2-01 freeze](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01)); no runtime unlock apply loop. Distinct from **`perk_unlock`** (**NEO-49** in **`PerkUnlockEngine`**) which may fire as a side effect of skill XP grants.
- **Manual QA doc:** **`docs/manual-qa/NEO-130.md`** — NEO-121/49 comment-only verification pattern.
## Goal, scope, and out-of-scope
**Goal:** Document and place **comment-only hook sites** on the server-authoritative quest completion reward path for future E9.M1 catalog events **`reward_delivery`** and **`unlock_granted`**. Align with Epic 7 Slice 2 telemetry vocabulary ([E7.M2 module](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md#related-implementation-slices)).
**In scope (from Linear + [E7M2-07](E7M2-prototype-backlog.md#e7m2-07--reward-telemetry-hook-sites-comment-only)):**
- **`reward_delivery`** hook site in **`RewardRouterOperations`** (first-time delivery success).
- **`unlock_granted`** stub comment (no runtime unlock apply in Slice 2).
- **`server/README.md`** reward telemetry subsection.
- **`E7_M2_RewardAndUnlockRouter.md`** implementation anchor.
- **`docs/manual-qa/NEO-130.md`** — verify comments + no behavior change.
- E7M2 backlog + alignment register updates when implementation completes.
**Out of scope (from Linear + backlog):**
- E9.M1 ingest pipeline; runtime telemetry emit.
- **`ILogger`** / metrics / dev-only log lines.
- Duplicate hook comments in **`QuestStateOperations`**, **`QuestAcceptApi`**, or **`QuestProgressApi`** (**`quest_complete`** remains NEO-121; **`reward_delivery`** is router-layer grant audit).
- Unlock engine / **`UnlockGrant`** runtime apply (pre-production).
- Client counterpart (none).
## Acceptance criteria checklist
- [x] Hook sites documented in README; no behavior change beyond comments.
## Implementation reconciliation (shipped)
- **Engine anchor:** **`RewardRouterOperations.TryDeliverQuestCompletion`** — two comment-only hook sites with **`TODO(E9.M1)`** (no runtime emit); class xml summary cites NEO-130 telemetry anchor.
- **`reward_delivery`:** after **`deliveryStore.TryRecord`** returns **`true`** on first-time delivery.
- **`unlock_granted`:** stub after item + skill XP apply loops, before delivery event build — future **`UnlockGrant`** apply region; prototype bundles have no unlock rows.
- **Docs:** `server/README.md` reward telemetry subsection; E7.M2 module + alignment register + E7M2 backlog updated.
- **Manual QA:** `docs/manual-qa/NEO-130.md`.
## Technical approach
### 1. Authoritative surface
**`RewardRouterOperations.TryDeliverQuestCompletion`** — all quest completion bundle grants funnel here after NEO-128 wiring. Encounter loot remains on **`EncounterCompletionOperations`** (E5.M3); quest bundles use this router only.
### 2. Hook site A — `reward_delivery`
Immediately after **`deliveryStore.TryRecord(deliveryEvent)`** returns **`true`** (first-time store commit):
- Comment block names future E9.M1 event **`reward_delivery`**.
- **`TODO(E9.M1): catalog emit`** — once per first-time quest completion delivery (idempotent **`TryGet`** replay and race-loser paths excluded).
- Planned payload fields from **`RewardDeliveryEvent`**: `playerId`, `questId`, `deliveredAt`, `grantedItems` (`itemId`, `quantity`), `grantedSkillXp` (`skillId`, `amount`), `idempotencyKey` (`{playerId}:quest_complete:{questId}`).
### 3. Hook site B — `unlock_granted` (stub)
Comment block after item + skill XP apply loops, **before** building/recording **`RewardDeliveryEvent`** (hypothetical future **`UnlockGrant`** apply region):
- Names future E9.M1 event **`unlock_granted`**.
- **`TODO(E9.M1): catalog emit`** — once per applied unlock row when bundle unlock grants land (pre-production).
- Note: prototype **`completionRewardBundle`** has **no** unlock rows; stub anchors future apply loop only — no runtime behavior in Slice 2.
- Planned payload fields (from module **`UnlockGrant`** contract): `playerId`, `questId`, unlock id / kind — finalize at E9.M1 wiring time.
### 4. Cross-references (minimal)
- Update **`RewardRouterOperations`** class summary → NEO-130 telemetry hook anchor.
- Optional one-line pointer in **`RewardDeliveryEvent.cs`** xml summary → **`reward_delivery`** hook site (mirror **`PerkUnlockEvent`** / NEO-49).
- **`server/README.md`:** new **Reward telemetry hooks (NEO-130)** subsection after [Reward router (NEO-127)](../../server/README.md#reward-router-neo-127) with event table.
- **`E7_M2_RewardAndUnlockRouter.md`:** NEO-130 landed line under implementation anchor.
- **`documentation_and_implementation_alignment.md`** + **`E7M2-prototype-backlog.md`:** E7M2-07 when complete.
### 5. Relationship to adjacent hooks
| Event | Layer | Story |
|-------|-------|-------|
| **`quest_complete`** | **`QuestStateOperations.TryMarkComplete`** after store mark | NEO-121 |
| **`reward_delivery`** | **`RewardRouterOperations`** after delivery store record | NEO-130 |
| **`perk_unlock`** | **`PerkUnlockEngine.TryUnlockPerks`** when skill XP grants unlock perks | NEO-49 |
| **`unlock_granted`** | **`RewardRouterOperations`** stub (future **`UnlockGrant`** apply) | NEO-130 |
Deliver-then-mark (NEO-128): **`reward_delivery`** fires **before** **`quest_complete`** in the same completion call stack — expected ordering for E9.M1 correlation.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/manual-qa/NEO-130.md` | Manual QA: verify comment blocks + no behavior/API change. |
| `docs/plans/NEO-130-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | **NEO-130:** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites. |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs` | Cross-reference **`reward_delivery`** hook anchor in type summary (no behavior change). |
| `server/README.md` | Document reward telemetry hook placement and Epic 7 Slice 2 vocabulary (NEO-130). |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | Implementation snapshot: NEO-130 telemetry hooks landed. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 row: NEO-130 hook sites. |
| `docs/plans/E7M2-prototype-backlog.md` | E7M2-07 checkboxes + landed note. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| *(none added or changed)* | Comments-only change; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing **`RewardRouterOperationsTests`**, quest wiring tests unchanged). Manual verification: **`docs/manual-qa/NEO-130.md`**. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Duplicate `reward_delivery` vs `quest_complete`** | Keep both — state transition vs grant audit are distinct catalog events; E9.M1 correlates by `playerId` + `questId` + timestamp. | **adopted** |
| **`unlock_granted` vs `perk_unlock`** | Stub documents future content **`UnlockGrant`** rows; do not conflate with perk engine side effects from skill XP. | **adopted** |
| **Encounter `reward_delivery` TODO (NEO-107)** | Leave E5.M3 producer comments as-is; NEO-130 anchors quest-router consumer path only. | **adopted** |

View File

@ -1,179 +0,0 @@
# NEO-131 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-131 |
| **Title** | E7M2-08: Client quest completion reward HUD (Godot) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-131/e7m2-08-client-quest-completion-reward-hud-godot |
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-08** |
| **Branch** | `NEO-131-client-quest-completion-reward-hud` |
| **Server deps** | [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) **`Done`** on `main``completionRewardSummary` on completed quest-progress rows |
| **Pattern** | [NEO-110](NEO-110-implementation-plan.md) — separate completion grant label + item-def display names; [NEO-122](NEO-122-implementation-plan.md) — `quest_hud_controller.gd` + event-driven GET refresh |
| **Blocks** | [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) — playable quest reward delivery capstone (E7M2-09) |
| **Server counterpart** | [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) — authoritative GET; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **HUD component** | Separate reward label vs inline on progress list? | **Separate `QuestRewardDeliveryLabel`** after **`QuestAcceptFeedbackLabel`** — NEO-110 **`EncounterCompleteLabel`** precedent; keeps four-quest progress compact. | **Adopted** — separate label |
| **Reward surfacing** | When to paint grant copy? | **Transition-only** — detect quest status → **`completed`** on refresh; matches Linear AC (“completing a quest shows…”); avoids replaying all summaries on boot/idempotency (NEO-123). | **Adopted** — transition-only |
| **Skill XP copy** | How to label skill lines without a skill-definitions client? | **Title-case `skillId` + amount** (e.g. **`Salvage +25 XP`**) — no skill-def client in repo; item lines use **`ItemDefinitionsClient`** like encounter loot. | **Adopted** (no kickoff question — settled by precedent) |
**Additional defaults (no kickoff question — settled by backlog / landed code):**
- **JSON shape:** nested **`completionRewardSummary`** with **`itemGrants: [{ itemId, quantity }]`** + **`skillXpGrants: [{ skillId, amount }]`** per NEO-129; empty **`itemGrants: []`** included when summary present.
- **Missing summary:** when **`status: completed`** but **`completionRewardSummary`** omitted (no delivery record), reward label stays **`—`** on transition — server omits field per NEO-129 kickoff.
- **Refresh strategy:** reuse NEO-122 triggers (boot hydrate + gather/craft/**`targetDefeated`**/encounter **`completed`**/accept POST) — no new poll or key.
- **Error surfacing:** failed GET continues **`sync error — {reason}`** on **`QuestProgressLabel`** (NEO-122); reward label shows **`—`** while sync error set (NEO-110 encounter loot precedent).
- **Server changes:** none — client-only story.
## Goal, scope, and out-of-scope
**Goal:** Godot shows **server-owned** quest completion reward grants using **`completionRewardSummary`** on **`GET /game/players/{id}/quest-progress`** — player-visible readable copy when a quest newly completes, without Bruno.
**In scope (from Linear + [E7M2-08](E7M2-prototype-backlog.md#e7m2-08--client-quest-completion-reward-hud-godot)):**
- Parse and retain **`completionRewardSummary`** on quest-progress rows (existing top-level parse passes through; add tests).
- **`QuestRewardDeliveryLabel`** under **`HudRoot`** after **`QuestAcceptFeedbackLabel`** — compact grant lines on in-session completion transition.
- Extend **`quest_hud_controller.gd`**: transition detection, reward label render, optional **`ItemDefinitionsClient`** for item display names.
- **`main.gd` / `main.tscn`:** wire reward label + item-defs client into controller **`setup`**.
- GdUnit parse + HUD render tests ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
- **`docs/manual-qa/NEO-131.md`** — component checklist (gather intro skill XP; operator chain item + XP).
- **`client/README.md`** quest reward HUD subsection.
**Out of scope (from Linear + backlog):**
- Final quest journal UI; reward VFX.
- Skill-definitions client; server route/DTO changes.
- Full Slice 2 capstone script (**[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)**).
- Inventory/skills auto-refresh on quest complete (optional follow-up; NEO-132 capstone may assert economy HUD separately).
## Acceptance criteria checklist
- [x] Completing a quest shows readable reward copy on HUD.
- [x] Failed GET surfaces visible error (NEO-122 pattern).
## Implementation reconciliation (shipped)
- **`quest_progress_client.gd`:** **`completion_reward_summary`** static helper; parse preserves NEO-129 nested summary on completed rows.
- **`quest_hud_controller.gd`:** transition-only detection (**`_previous_status_by_quest`**); **`QuestRewardDeliveryLabel`** render with item-def display names + title-case skill XP lines; sync error idle state.
- **`main.gd` / `main.tscn`:** **`QuestRewardDeliveryLabel`** after accept feedback; **`ItemDefinitionsClient`** threaded into controller **`setup`**.
- **Tests:** `quest_progress_client_test.gd` (summary parse); `quest_hud_controller_test.gd` (transition, boot idempotency, first-success-after-active, sync error, item grant display name, quest/item `definitions_ready` reward repaint).
- **Docs:** `client/README.md`; `docs/manual-qa/NEO-131.md`; E7.M2 module + backlog alignment.
## Technical approach
### Server contract (landed — NEO-129)
Per completed quest row when delivery recorded:
```json
{
"questId": "prototype_quest_gather_intro",
"status": "completed",
"completionRewardSummary": {
"itemGrants": [],
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
}
}
```
Operator chain adds item line: **`survey_drone_kit` ×1** + **`salvage` 50** XP.
### 1. Parse layer (`quest_progress_client.gd`)
- No parser change required — **`parse_quest_progress_json`** already accepts arbitrary quest row fields.
- Optional static helper **`completion_reward_summary(quest_id, snapshot) -> Dictionary`** returning **`{}`** when absent — keeps HUD code readable (mirror **`encounter_row`** usage).
- GdUnit: completed row JSON with nested summary preserves **`itemGrants`** / **`skillXpGrants`** through parse + **`quest_row`**.
### 2. Transition detection (`quest_hud_controller.gd`)
- Maintain **`_previous_status_by_quest: Dictionary`** (questId → last known status string).
- On **`_on_progress_received`**, before overwriting snapshot:
- For each quest row in new snapshot, compare **`status`** to previous.
- When previous ∉ **`{completed}`** and new **`status == "completed"`** with non-empty **`completionRewardSummary`**, set **`_last_reward_quest_id`** + **`_last_reward_summary`**.
- On boot first snapshot: seed **`_previous_status_by_quest`** without firing transition unless the quest was **`active` in-session** (accept or prior active row) — covers failed GET then first success already **`completed`**; Godot restart idempotency unchanged.
- On sync failure: clear **`_last_reward_summary`** paint to **`—`**; preserve NEO-122 progress error lines.
### 3. Reward label render
**Header:** **`Quest rewards:`** (idle **`Quest rewards:\n—`**).
When **`_last_reward_quest_id`** set and summary present:
```
Quest rewards:
Intro: Salvage Run
Salvage +25 XP
```
Item lines: **`{displayName} ×{quantity}`** via **`ItemDefinitionsClient.display_name_for`**, fallback **`itemId`**.
Skill lines: **`{_title_case_skill_id(skillId)} +{amount} XP`**.
When **`completed`** transition but summary omitted: show quest display name + **`(rewards pending)`** or **`—`** — prefer **`—`** only (no delivery record = no copy; keeps label honest).
### 4. Scene + wiring
- **`main.tscn`:** add **`QuestRewardDeliveryLabel`** after **`QuestAcceptFeedbackLabel`** (same font/theme as quest labels).
- **`main.gd`:** `@onready` reward label; extend **`_setup_quest_progress_sync()`** to pass reward label + **`_item_defs_client`** into controller **`setup`** (new optional params at end for backward-compatible test calls).
### 5. Display copy reference (freeze table)
| Quest id | Expected reward lines |
|----------|----------------------|
| `prototype_quest_gather_intro` | Salvage +25 XP |
| `prototype_quest_refine_intro` | Refine +25 XP |
| `prototype_quest_combat_intro` | Salvage +25 XP |
| `prototype_quest_operator_chain` | Survey Drone Kit ×1; Salvage +50 XP |
### 6. Relationship to adjacent HUD
| HUD element | Story | Role |
|-------------|-------|------|
| **`QuestProgressLabel`** | NEO-122 | Status/step/counters for all four quests |
| **`QuestRewardDeliveryLabel`** | NEO-131 | Grant copy on completion transition |
| **`EncounterCompleteLabel`** | NEO-110 | Encounter loot (separate from quest completion bundles) |
Combat intro completion is **skill XP only** in quest bundle — encounter token loot remains on encounter label (E7M2 freeze).
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-131-implementation-plan.md` | This plan. |
| `docs/manual-qa/NEO-131.md` | Manual QA: gather complete → skill XP line; operator chain item + XP; sync error visible. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scripts/quest_hud_controller.gd` | Transition detection, reward label render, item-def display names, sync-error idle state. |
| `client/scripts/quest_progress_client.gd` | Optional **`completion_reward_summary`** helper; class doc cites NEO-131 field. |
| `client/scripts/main.gd` | Wire **`QuestRewardDeliveryLabel`** + **`ItemDefinitionsClient`** into quest HUD **`setup`**. |
| `client/scenes/main.tscn` | Add **`QuestRewardDeliveryLabel`** node after accept feedback label. |
| `client/README.md` | Quest completion reward HUD subsection; cross-link NEO-129/131. |
| `docs/plans/E7M2-prototype-backlog.md` | E7M2-08 checkboxes + landed note when complete. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | Client implementation anchor (NEO-131). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 row: NEO-131 client HUD. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `client/test/quest_progress_client_test.gd` | **Add:** parse completed row with **`completionRewardSummary`** (empty **`itemGrants`**, skill XP row); helper returns summary dict. |
| `client/test/quest_reward_hud_test.gd` | **Add:** transition **`active``completed`** paints reward label with skill XP line; boot with already-**`completed`** does not paint; first-success-after-active; sync failure idle reward; operator-chain item grant + display name; quest/item **`definitions_ready`** reward repaint. |
**Regression:** existing NEO-122 GdUnit suites unchanged; `dotnet test` N/A (client-only).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Multiple quests complete in one GET** | Paint **last catalog-order** transition in snapshot iteration — rare in prototype; document in manual QA. | **adopted** |
| **Inventory deny on complete (NEO-128)** | Quest may stay **`active`** when router denies — no **`completed`** transition, no reward label; acceptable for prototype. | **adopted** |
| **Skill display without defs client** | Title-case **`skillId`**; defer skill-def client to pre-production. | **adopted** |
| **NEO-132 capstone overlap** | NEO-131 proves component HUD; NEO-132 owns end-to-end reward idempotency manual QA. | **deferred** |

View File

@ -1,60 +0,0 @@
# Code review — NEO-128 (E7M2-05)
**Date:** 2026-06-07
**Scope:** Branch `NEO-128-e7m2-quest-complete-reward-wiring` vs `origin/main` — commits `7abe7dc``da99769`
**Base:** `origin/main`
## Verdict
**Approve with nits** — deliver-then-mark wiring in `QuestStateOperations.TryMarkComplete`, idempotent skip, deny passthrough, and dependency threading through objective wiring and upstream ops match the adopted plan and NEO-105 precedent; tests cover the primary AC paths; risk is low (server-internal until NEO-129 HTTP projection).
## Summary
NEO-128 wires **`RewardRouterOperations.TryDeliverQuestCompletion`** into **`QuestStateOperations.TryMarkComplete`** on the first-time active→completed path: bundle delivery runs before **`IPlayerQuestStateStore.TryMarkComplete`**, idempotent **`completed`** replay returns early without calling the router, and delivery denies passthrough router **`reasonCode`** values while leaving the quest **active**. Reward-router dependencies are threaded through **`QuestObjectiveWiring`**, **`GatherOperations`**, **`CraftOperations`**, **`EncounterCompletionOperations`**, **`QuestProgressApi`**, **`QuestAcceptApi`**, **`AbilityCastApi`**, and related call sites so wiring-driven completion exercises the same path. Five new/extended AAA integration tests lock gather-intro bundle delivery, idempotent replay (direct and wiring), and inventory-full deny on operator-chain setup; compile-fix updates propagate the expanded signatures across quest/gather/craft/encounter test harnesses. Docs (plan reconciliation, E7.M2 anchor, alignment register, backlog AC, `server/README.md`) are updated. A minimal Bruno health smoke was added (host boot check only).
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-128-implementation-plan.md` | **Matches** — kickoff deliver-then-mark and idempotent skip adopted; reconciliation accurate; AC checked. |
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-05) | **Matches** — AC checkboxes complete; Bruno listed as optional in backlog (health-only smoke landed). |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-128 implementation anchor added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-05 quest-state wiring; row correctly stays **Planned** until NEO-132 capstone. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required. |
| `server/README.md` | **Matches**`TryMarkComplete` table row and reward-router cross-ref document deliver-then-mark, idempotent skip, deny passthrough. |
| Full-stack epic decomposition | **N/A** — E7M2-05 is server engine; client counterpart NEO-131 correctly deferred (blocked by NEO-129). |
**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate.
## Blocking issues
None.
## Suggestions
1. ~~**Operator-chain happy-path delivery assertions** — The plan lists integration coverage for “operator-chain → item + skill grants.” `TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull` exercises chain quest at the operations layer (deny only). `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet` completes via wiring but does not assert **`IRewardDeliveryStore`**, **`survey_drone_kit`**, or salvage +50. NEO-127 covers router apply in isolation; extending the wiring or operations happy-path test would lock end-to-end **`TryMarkComplete`** + item grant for the only bundle with **`itemGrants`**.~~ **Done.** Added `TryMarkComplete_ShouldDeliverOperatorChainBundle_WhenFirstCompletion`; extended `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet` with store, item, and skill XP asserts.
2. ~~**Inventory-full via wiring path** — Deny is tested via direct **`TryMarkComplete`** after manual chain setup. Optional: one wiring-driven test (e.g. fill bag then complete terminal **`inventory_has_item`** step) to prove **`QuestObjectiveWiring`** best-effort ignore does not mask the deny for primary ops while quest stays **active** for retry.~~ **Done.** Added `ChainQuest_ShouldStayActive_WhenBagFullOnTerminalWiringComplete` (terminal advance via `TryAdvanceStep` while bag full; quest stays **active**, no store record).
## Nits
- ~~Nit: Kickoff adopted “skip Bruno” for bundle verification, but a **`GET /health`** smoke was added under `bruno/neon-sprawl-server/quest-progress/` with docs pointing to dotnet test for bundle asserts. Harmless and consistent with backlog “optional” wording; consider a one-line plan note if authors want kickoff vs backlog Bruno policy explicit.~~ **Done.** Plan kickoff clarifications note health-only Bruno for pre-commit hook; functional verification via `dotnet test`.
- Nit: **`TryCompleteStepIfSatisfied`** discards **`TryMarkComplete`** return value (by design per plan — wiring best-effort). Inventory-full deny therefore does not surface on gather/craft/encounter envelopes; acceptable for prototype, worth remembering for NEO-131 client surfacing.
- Nit: Delivery succeeds then **`TryMarkComplete`** store write fails — orphan store record self-heals via **`already_delivered`** on retry (plan deferred). No action required for prototype.
- Nit: Expanded parameter lists on **`TryAccept`** / **`TryAdvanceStep`** mirror NEO-118 threading precedent; a future facade/DI record could reduce call-site churn but is explicitly out of scope.
## Verification
```bash
dotnet test server/NeonSprawl.Server.Tests --filter "QuestStateOperationsTests|QuestObjectiveWiringTests"
dotnet test server/NeonSprawl.Server.Tests --filter "FullyQualifiedName~Quests|FullyQualifiedName~GatherOperations|FullyQualifiedName~CraftOperations|FullyQualifiedName~EncounterCompletion|FullyQualifiedName~EncounterCombatWiring"
```
All 32 quest-state/wiring tests and 141 related tests pass (verified 2026-06-07; +2 after review follow-up).
Optional before merge:
- Boot dev server; run Bruno `GET health (quest complete reward wiring NEO-128)` or `curl /health` — no new DI registration beyond existing reward stores.

View File

@ -1,56 +0,0 @@
# Code review — NEO-129 (E7M2-06)
**Date:** 2026-06-07
**Scope:** Branch `NEO-129-quest-progress-completion-reward-summary` vs `main` — commits `2b06eb2``78e805e`
**Base:** `main`
**Follow-up:** Review suggestions and nits addressed in a subsequent commit on the same branch.
## Verdict
**Approve with nits** — additive `completionRewardSummary` on completed quest-progress rows, sourced from `IRewardDeliveryStore`, matches the adopted plan and NEO-108 encounter precedent; mapper threading through `QuestAcceptApi` is correct; tests and Bruno cover primary AC paths; risk is low (server-only until NEO-131 client parse).
## Summary
NEO-129 extends **`GET /game/players/{id}/quest-progress`** with optional **`completionRewardSummary`** on **`completed`** rows: nested **`itemGrants`** + **`skillXpGrants`** mapped from **`RewardDeliveryEvent`** snapshots via **`IRewardDeliveryStore.TryGet`**, omitted for **`not_started`** / **`active`** and when no delivery record exists (kickoff). **`QuestProgressApi.BuildSnapshot`** / **`MapQuestProgressRow`** accept **`playerId`** + **`deliveryStore`**; **`QuestAcceptApi.MapResponse`** uses the same mapper so embedded accept rows stay consistent. DTOs in **`QuestProgressListDtos.cs`** use **`JsonIgnore(WhenWritingNull)`** and preserve commit-time grant order (XML on mappers mirrors NEO-108). Eight AAA integration tests in **`QuestProgressApiTests`** assert omit on default/active, gather-intro salvage **25**, operator-chain item + XP, and idempotent GET replay; Bruno adds gather-intro complete spine plus default GET omit assertion. Docs (plan reconciliation, E7.M2 anchor, alignment register, E7M2-06 backlog AC, **`server/README.md`**) are updated.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-129-implementation-plan.md` | **Matches** — kickoff nested shape and omit-on-miss adopted; reconciliation accurate; AC checked. |
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-06) | **Matches** — AC checkboxes complete; client counterpart NEO-131 correctly deferred. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-129 HTTP read anchor added; separated from NEO-128 wiring note. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-06 HTTP read; row correctly stays **Planned** until NEO-132 capstone. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required. |
| `server/README.md` | **Matches** — quest-progress section documents **`completionRewardSummary`** projection source and gather-intro sample. |
| Full-stack epic decomposition | **N/A** — E7M2-06 is server HTTP read; client counterpart [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) explicitly out of scope. |
**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate.
## Blocking issues
None.
## Suggestions
1. ~~**GET-refresh chain completion summary** — `GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet` asserts **`completed`** via inventory refresh wiring but does not assert **`CompletionRewardSummary`** (unlike gather and operator-chain complete tests). That path exercises deliver-then-mark; adding item + skill XP asserts would lock summary projection on the GET side-effect completion path.~~ **Done.** `AssertOperatorChainCompletionRewardSummary` on GET-refresh chain terminal test.
2. ~~**Bruno idempotent second GET** — The implementation plan Bruno AC calls for a second GET unchanged; **`Get quest progress after gather intro complete.bru`** performs only one GET. Optional follow-up: duplicate GET in pre-request or a second request file to match plan and integration test coverage.~~ **Done.** Pre-request first GET + `bru.setVar`; main GET test asserts `eql` with stored summary.
3. ~~**Completed without delivery record** — Kickoff adopts omit-on-miss (softer than NEO-108 fail-fast). No test forces **`status: completed`** with a store miss (e.g. direct **`IPlayerQuestStateStore`** seed without **`IRewardDeliveryStore`** record). Low priority given NEO-128 deliver-then-mark invariant in normal paths; a single defensive test would document the contract.~~ **Done.** `GetQuestProgress_ShouldOmitCompletionRewardSummary_WhenCompletedWithoutDeliveryRecord`.
## Nits
- ~~Nit: Idempotent replay test (`GetQuestProgress_ShouldReturnSameCompletionRewardSummary_WhenGatherIntroCompletedTwice`) compares only the first **`skillXpGrants`** line, not full **`completionRewardSummary`** object equality (Bruno uses **`eql`** on arrays). Fine for single-line gather intro; operator-chain idempotent replay is untested.~~ **Done.** `AssertCompletionRewardSummariesEqual` helper; added operator-chain idempotent replay test.
- ~~Nit: **`QuestAcceptApiTests`** do not assert **`CompletionRewardSummary`** null on active accept embedded rows. Mapper consistency is covered indirectly via shared **`MapQuestProgressRow`**; a one-line assert on existing accept-success tests would mirror GET omit coverage.~~ **Done.** Null on active accept; summary on already-completed deny row.
- Nit: Grant-order XML is present on **`MapItemGrants`** / **`MapSkillXpGrants`**; C# operator-chain test asserts fields via **`Single`** / index **0** (order-agnostic for single grants). Acceptable for prototype freeze table; Bruno gather test asserts ordered **`skillXpGrants`** array.
## Verification
```bash
cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~QuestProgressApiTests"
```
Optional: run Bruno folder `bruno/neon-sprawl-server/quest-progress/` against a running dev server (especially **`Get quest progress after gather intro complete.bru`**).

View File

@ -1,58 +0,0 @@
# Code review — NEO-130 (E7M2-07)
**Date:** 2026-06-07
**Scope:** Branch `NEO-130-reward-telemetry-hooks` vs `origin/main` — commits `7493a6b``c461a82`
**Base:** `origin/main`
**Follow-up:** Review suggestions/nits addressed in a subsequent commit on the same branch.
## Verdict
**Approve with nits**
## Summary
NEO-130 adds **comment-only** E9.M1 telemetry hook sites to **`RewardRouterOperations.TryDeliverQuestCompletion`** for Epic 7 Slice 2 events **`reward_delivery`** (after first-time **`IRewardDeliveryStore.TryRecord`** success) and **`unlock_granted`** (stub region after item + skill XP apply, before **`RewardDeliveryEvent`** construction). Hook placement correctly excludes idempotent **`TryGet`** replay, deny paths, and **`TryRecord`** race-loser rollback. No runtime ingest, **`ILogger`**, wire contract, or API changes. Documentation matches NEO-121/NEO-49 precedent: implementation plan reconciliation, **`server/README.md`** event table, E7.M2 module snapshot, alignment register, E7M2-07 backlog checkboxes, **`RewardDeliveryEvent`** xml cross-ref, and **`docs/manual-qa/NEO-130.md`**. Infrastructure-only slice with no client counterpart. Risk is negligible.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-130-implementation-plan.md` | **Matches** — kickoff timing decisions adopted; acceptance checklist checked; reconciliation accurate. |
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-07) | **Matches** — acceptance checkbox checked; landed note + README link. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-130 telemetry hook snapshot added under implementation anchor. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M2-07 / NEO-130 landed note appended; row stays **Planned** until NEO-132 capstone. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register row change required (detail lives in alignment note). |
| `server/README.md` | **Matches** — Reward telemetry hooks subsection with event table, delegation note, and **`perk_unlock`** distinction. |
| `docs/manual-qa/NEO-130.md` | **Matches** — comment-only verification checklist; optional regression/Bruno steps. |
| Full-stack epic decomposition | **N/A** — infrastructure-only; plan and backlog explicitly document no client counterpart. |
**Register / tracking:** E7.M2 correctly remains **Planned** until client capstone NEO-132; E7M2-07 completion noted in alignment register and module snapshot only — no further status change required.
## Blocking issues
(none)
## Suggestions
1. ~~**E9.M1 `unlock_granted` wiring note** — The stub sits before **`TryRecord`**, so a future literal emit at this line would also run on the race-rollback path (grants applied then reverted). When **`UnlockGrant`** rows land, place per-row emits inside the apply loop and/or gate on durable commit (mirror **`reward_delivery`** after **`TryRecord`** success). The stub comment already says “once per applied UnlockGrant row”; consider adding one line that durable emit should not fire on rollback paths when E9.M1 implements the loop.~~ **Done.** Stub comment now gates E9.M1 durable emit to apply-loop and/or post-**`TryRecord`** paths only (not race rollback).
## Nits
- ~~Nit: **`unlock_granted`** stub runs even for empty bundles (no item/skill rows) — correct for a region marker; E9.M1 loop will simply emit nothing until unlock rows exist in content.~~ **Done.** Acknowledged; no change.
- ~~Nit: Plan technical approach lists class-summary update on **`RewardRouterOperations`**; shipped in xml summary — reconciliation bullet could mention it for parity with other stories (cosmetic doc only).~~ **Done.** Reconciliation bullet updated.
## Verification
```bash
# Router-focused subset (14 passed at review time)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~RewardRouterOperations"
# Full regression (comments-only; no new tests)
dotnet test NeonSprawl.sln
```
Manual (per `docs/manual-qa/NEO-130.md`):
- Walk **`RewardRouterOperations.cs`** hook comment blocks; confirm **`TODO(E9.M1)`**, no **`ILogger`**, and no duplicate hooks in **`QuestStateOperations`**, **`QuestAcceptApi`**, or **`QuestProgressApi`**.
- Optional Bruno sanity in `bruno/neon-sprawl-server/quest-progress/` — gather intro complete + health after reward wiring unchanged.

View File

@ -1,68 +0,0 @@
# Code review — NEO-131 (E7M2-08)
**Date:** 2026-06-07
**Scope:** Branch `NEO-131-client-quest-completion-reward-hud` vs `origin/main` — commits `5578de9``ed7975c`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-131 adds Godot client surfacing for server-owned quest completion grants: **`completion_reward_summary`** helper on **`quest_progress_client.gd`**, transition-only detection in **`quest_hud_controller.gd`** (`_previous_status_by_quest` boot-seed + catalog-order tie-break for multi-complete GETs), and **`QuestRewardDeliveryLabel`** wired from **`main.gd` / `main.tscn`** with optional **`ItemDefinitionsClient`** display names. Behavior matches adopted kickoff decisions (separate label, transition-only paint, title-case skill XP, sync-error idle **`—`** on reward label while NEO-122 progress error persists). Four new GdUnit cases use full AAA layout. Documentation is complete: implementation plan reconciliation, E7M2-08 backlog checkboxes, E7.M2 module anchor, alignment register note, **`client/README.md`**, and **`docs/manual-qa/NEO-131.md`**. Client-only slice with NEO-129 server dependency already on `main`; does not claim E7 Slice 2 capstone (NEO-132). Risk is low.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-131-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation accurate. |
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-08) | **Matches** — acceptance checkboxes checked; landed note + README/plan/manual QA links. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-131 client HUD snapshot under implementation anchor. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M2-08 / NEO-131 landed note in row body; references column includes [NEO-131 plan](../../plans/NEO-131-implementation-plan.md). |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register row change required. |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — client displays GET snapshot only; no local grant math. |
| `client/README.md` | **Matches** — quest completion reward HUD subsection with routes, triggers, error pattern. |
| `docs/manual-qa/NEO-131.md` | **Matches** — Godot-only checklist; boot idempotency + operator chain optional steps. |
| Full-stack epic decomposition | **Matches** — paired client issue NEO-131; server counterpart NEO-129 documented; does not claim E7M2-09 capstone complete. |
**Register / tracking:** E7.M2 correctly remains **Planned** until client capstone NEO-132; no further status change required for this merge.
## Blocking issues
(none)
## Suggestions
1. ~~**GdUnit: item grant + display name** — New HUD tests cover skill XP transition, boot idempotency, and sync-error idle state. A small case with a mock **`ItemDefinitionsClient`** (or stub **`display_name_for`**) asserting **`Survey Drone Kit ×1`** would lock the operator-chain path referenced in manual QA and the freeze table without requiring Godot gameplay.~~ **Done.** `test_completion_transition_paints_item_grant_with_display_name` + **`StubItemDefsClient`** in `client/test/quest_hud_controller_test.gd`.
2. ~~**GdUnit: `definitions_ready` reward repaint** — **`_on_definitions_ready`** repaints the reward label when **`_last_reward_quest_id`** is set (quest display name upgrade). Mirror the existing NEO-122 progress-label test pattern for the reward label so regressions in **`_display_name`** wiring are caught.~~ **Done.** `test_definitions_ready_repaints_reward_label_with_display_name` in `client/test/quest_hud_controller_test.gd`.
## Nits
- Nit: **`_format_reward_summary_lines`** appends **`(no grants)`** when summary dict is present but all grant rows are filtered out (empty arrays or zero qty). Server delivery snapshots should always include at least one grant when **`completionRewardSummary`** is emitted; harmless defensive copy.
- ~~Nit: **`documentation_and_implementation_alignment.md`** E7.M2 references pipe lists NEO-124NEO-130 plan links but not **`NEO-131-implementation-plan.md`** — row body already cites NEO-131; add link for parity with other landed slices.~~ **Done.** References column now includes [NEO-131 plan](../../plans/NEO-131-implementation-plan.md).
- Nit: Encounter loot (**NEO-110**) repaints on every **`completed`** snapshot; quest rewards intentionally use transition-only detection — correct per plan; keep the distinction in mind when NEO-132 capstone asserts end-to-end economy HUD refresh.
## Verification
```bash
# GdUnit (requires GODOT_BIN)
export GODOT_BIN=/path/to/godot # or pass --godot_binary
cd client
./addons/gdUnit4/runtest.sh -a test/quest_progress_client_test.gd -a test/quest_hud_controller_test.gd
# GDScript lint (if .venv-gd installed)
gdlint client/scripts/quest_hud_controller.gd client/scripts/quest_progress_client.gd client/scripts/main.gd
```
Manual (per `docs/manual-qa/NEO-131.md`):
1. Fresh server + Godot **F5** — reward label **`Quest rewards:` / `—`** below accept feedback.
2. Accept gather intro, complete 3× scrap — **`Salvage +25 XP`** on transition; progress label shows **`completed`**.
3. Godot restart (server still running) — reward label stays **`—`** (no replay).
4. Optional: operator chain completion — item + skill XP lines; optional server stop — progress **`sync error`** + reward **`—`**.
Regression: NEO-122 quest progress/accept HUD and NEO-110 encounter loot label remain independent.

View File

@ -3,7 +3,6 @@ using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -36,8 +35,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -82,8 +79,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -119,8 +114,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -158,8 +151,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -194,8 +185,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -231,8 +220,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -265,8 +252,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -295,8 +280,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -328,8 +311,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -363,8 +344,6 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -413,9 +392,7 @@ public sealed class CraftOperationsTests
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>());
services.GetRequiredService<IPlayerQuestStateStore>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -452,9 +429,7 @@ public sealed class CraftOperationsTests
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
IPlayerQuestStateStore ProgressStore);
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
{

View File

@ -1,10 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -40,12 +37,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -77,12 +68,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -118,12 +103,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -157,12 +136,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -189,12 +162,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
@ -213,12 +180,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -251,12 +212,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -298,12 +253,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -342,12 +291,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -370,12 +313,6 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
}
@ -426,13 +363,7 @@ public sealed class EncounterCombatWiringTests
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>());
services.GetRequiredService<IPlayerQuestStateStore>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -458,11 +389,5 @@ public sealed class EncounterCombatWiringTests
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore QuestProgressStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
IPlayerQuestStateStore QuestProgressStore);
}

View File

@ -1,10 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -40,12 +37,6 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider);
// Assert
@ -89,12 +80,6 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
@ -111,12 +96,6 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider);
// Assert
@ -175,12 +154,6 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -215,12 +188,6 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -257,12 +224,6 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -297,12 +258,6 @@ public sealed class EncounterCompletionOperationsTests
inventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -335,12 +290,6 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -403,13 +352,7 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>());
services.GetRequiredService<IPlayerQuestStateStore>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -449,13 +392,7 @@ public sealed class EncounterCompletionOperationsTests
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore QuestProgressStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
IPlayerQuestStateStore QuestProgressStore);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{

View File

@ -3,7 +3,6 @@ using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -37,8 +36,6 @@ public sealed class GatherOperationsTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -93,8 +90,6 @@ public sealed class GatherOperationsTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -131,8 +126,6 @@ public sealed class GatherOperationsTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
Assert.True(gather.Success);
}
@ -154,8 +147,6 @@ public sealed class GatherOperationsTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -191,8 +182,6 @@ public sealed class GatherOperationsTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _);
@ -226,8 +215,6 @@ public sealed class GatherOperationsTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -262,8 +249,6 @@ public sealed class GatherOperationsTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -288,9 +273,7 @@ public sealed class GatherOperationsTests
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IResourceNodeInstanceStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>());
services.GetRequiredService<IPlayerQuestStateStore>());
}
private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
@ -328,9 +311,7 @@ public sealed class GatherOperationsTests
PerkUnlockEngine PerkUnlockEngine,
IResourceNodeInstanceStore InstanceStore,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
IPlayerQuestStateStore ProgressStore);
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
{

View File

@ -4,10 +4,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -76,7 +73,6 @@ public sealed class QuestAcceptApiTests
Assert.Equal(0, body.Quest.CurrentStepIndex);
Assert.Empty(body.Quest.ObjectiveCounters);
Assert.Null(body.Quest.CompletedAt);
Assert.Null(body.Quest.CompletionRewardSummary);
var getResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
@ -206,45 +202,22 @@ public sealed class QuestAcceptApiTests
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var services = factory.Services;
var questRegistry = services.GetRequiredService<IQuestDefinitionRegistry>();
var progressStore = services.GetRequiredService<IPlayerQuestStateStore>();
var inventoryStore = services.GetRequiredService<IPlayerInventoryStore>();
var itemRegistry = services.GetRequiredService<IItemDefinitionRegistry>();
var skillRegistry = services.GetRequiredService<ISkillDefinitionRegistry>();
var skillProgressionStore = services.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = services.GetRequiredService<ISkillLevelCurve>();
var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>();
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
var questRegistry = factory.Services.GetRequiredService<IQuestDefinitionRegistry>();
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(
PlayerId,
GatherQuestId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
factory.Services.GetRequiredService<IPlayerInventoryStore>(),
factory.Services.GetRequiredService<IItemDefinitionRegistry>(),
timeProvider).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
questRegistry,
progressStore,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider).Success);
// Act
@ -262,11 +235,6 @@ public sealed class QuestAcceptApiTests
Assert.Equal(QuestProgressApi.StatusCompleted, body.Quest!.Status);
Assert.Equal(0, body.Quest.CurrentStepIndex);
Assert.NotNull(body.Quest.CompletedAt);
Assert.NotNull(body.Quest.CompletionRewardSummary);
Assert.Empty(body.Quest.CompletionRewardSummary!.ItemGrants);
Assert.Single(body.Quest.CompletionRewardSummary.SkillXpGrants);
Assert.Equal("salvage", body.Quest.CompletionRewardSummary.SkillXpGrants[0].SkillId);
Assert.Equal(25, body.Quest.CompletionRewardSummary.SkillXpGrants[0].Amount);
}
[Fact]

View File

@ -5,7 +5,6 @@ using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -42,37 +41,6 @@ public sealed class QuestObjectiveWiringTests
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
Assert.Equal(
3 * GatherSkillXpConstants.ActivityXpPerResourceNodeGather + 25,
deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent));
Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal(25, deliveryEvent.GrantedSkillXp[0].Amount);
}
[Fact]
public async Task GatherIntro_ShouldNotDoubleGrantXp_WhenCompleteReplayed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(TryAccept(deps, GatherQuestId).Success);
for (var i = 0; i < 3; i++)
{
Assert.True(TryGather(deps, AlphaNodeId).Success);
}
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var firstEvent));
var salvageXpBeforeReplay = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
// Act
var replay = TryMarkComplete(deps, GatherQuestId);
// Assert
Assert.True(replay.Success);
Assert.Equal(salvageXpBeforeReplay, deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var replayEvent));
Assert.Equal(firstEvent.IdempotencyKey, replayEvent.IdempotencyKey);
}
[Fact]
@ -110,12 +78,6 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
// Assert
@ -148,56 +110,12 @@ public sealed class QuestObjectiveWiringTests
// Act — step 2: field stim craft
SeedStack(deps, "refined_plate_stock", 2);
SeedStack(deps, "scrap_metal_bulk", 1);
var salvageXpBeforeComplete = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
var kitBefore = CountBagItem(beforeInventory!, "survey_drone_kit");
Assert.True(TryCraft(deps, "make_field_stim_mk0").Success);
// Assert — terminal inventory_has_item (contract_handoff_token from combat intro)
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var completed));
Assert.Equal(QuestProgressStatus.Completed, completed.Status);
Assert.Equal(3, completed.CurrentStepIndex);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out var deliveryEvent));
Assert.Single(deliveryEvent.GrantedItems);
Assert.Equal("survey_drone_kit", deliveryEvent.GrantedItems[0].ItemId);
Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount);
Assert.Equal(salvageXpBeforeComplete + 50, deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(kitBefore + 1, CountBagItem(afterInventory!, "survey_drone_kit"));
}
[Fact]
public async Task ChainQuest_ShouldStayActive_WhenBagFullOnTerminalWiringComplete()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
AcceptAndMarkComplete(deps, GatherQuestId);
AcceptAndMarkComplete(deps, RefineQuestId);
AcceptAndMarkComplete(deps, CombatQuestId);
SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1);
Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _));
Assert.True(TryAdvanceStep(deps, ChainQuestId, 1).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _));
Assert.True(TryAdvanceStep(deps, ChainQuestId, 2).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _));
FillRemainingBagSlots(deps);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
var kitBefore = CountBagItem(beforeInventory!, "survey_drone_kit");
// Act
var advance = TryAdvanceStep(deps, ChainQuestId, 3);
// Assert
Assert.True(advance.Success);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Active, progress.Status);
Assert.Equal(3, progress.CurrentStepIndex);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out _));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(kitBefore, CountBagItem(afterInventory!, "survey_drone_kit"));
}
[Fact]
@ -229,13 +147,37 @@ public sealed class QuestObjectiveWiringTests
SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1);
Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _));
Assert.True(TryAdvanceStep(deps, ChainQuestId, 1).Success);
Assert.True(QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _));
Assert.True(TryAdvanceStep(deps, ChainQuestId, 2).Success);
Assert.True(QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
2,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _));
// Act
var advance = TryAdvanceStep(deps, ChainQuestId, 3);
var advance = QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
3,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider);
// Assert
Assert.True(advance.Success);
@ -259,12 +201,6 @@ public sealed class QuestObjectiveWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
Assert.True(result.Success);
}
@ -296,51 +232,17 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
QuestStateOperations.TryAdvanceStep(
PlayerId,
questId,
step,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
{
Assert.True(TryAccept(deps, questId).Success);
Assert.True(TryMarkComplete(deps, questId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider).Success);
}
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
@ -357,8 +259,6 @@ public sealed class QuestObjectiveWiringTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
@ -375,40 +275,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static void FillRemainingBagSlots(QuestWiringTestDependencies deps)
{
deps.InventoryStore.TryGetSnapshot(PlayerId, out var snapshot);
var emptySlots = snapshot!.BagSlots.Count(static slot => slot.IsEmpty);
for (var i = 0; i < emptySlots; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
PlayerId,
"survey_drone_kit",
quantity: 1,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
}
}
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
{
var outcome = PlayerInventoryOperations.TryAddStack(
@ -440,8 +308,6 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
TimeProvider.System);
}
@ -462,7 +328,5 @@ public sealed class QuestObjectiveWiringTests
IEncounterProgressStore EncounterProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
TimeProvider TimeProvider);
}

View File

@ -7,7 +7,6 @@ using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -79,7 +78,6 @@ public sealed class QuestProgressApiTests
Assert.Equal(0, row.CurrentStepIndex);
Assert.Empty(row.ObjectiveCounters);
Assert.Null(row.CompletedAt);
Assert.Null(row.CompletionRewardSummary);
}
}
@ -110,7 +108,6 @@ public sealed class QuestProgressApiTests
Assert.Equal(0, row.CurrentStepIndex);
Assert.Equal(2, row.ObjectiveCounters[GatherObjectiveId]);
Assert.Null(row.CompletedAt);
Assert.Null(row.CompletionRewardSummary);
}
[Fact]
@ -137,87 +134,6 @@ public sealed class QuestProgressApiTests
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(0, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
AssertGatherIntroCompletionRewardSummary(row.CompletionRewardSummary);
}
[Fact]
public async Task GetQuestProgress_ShouldReturnSameCompletionRewardSummary_WhenGatherIntroCompletedTwice()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var client = factory.CreateClient();
Assert.True(TryAccept(deps, GatherQuestId).Success);
for (var i = 0; i < 3; i++)
{
Assert.True(TryGather(deps, AlphaNodeId).Success);
}
// Act
var firstResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
var secondResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
var firstBody = await firstResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
var secondBody = await secondResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(firstBody);
Assert.NotNull(secondBody);
var firstRow = Assert.Single(firstBody!.Quests, static r => r.QuestId == GatherQuestId);
var secondRow = Assert.Single(secondBody!.Quests, static r => r.QuestId == GatherQuestId);
AssertGatherIntroCompletionRewardSummary(firstRow.CompletionRewardSummary);
AssertCompletionRewardSummariesEqual(firstRow.CompletionRewardSummary, secondRow.CompletionRewardSummary);
}
[Fact]
public async Task GetQuestProgress_ShouldReturnSameCompletionRewardSummary_WhenOperatorChainCompletedTwice()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var client = factory.CreateClient();
CompleteOperatorChain(deps);
// Act
var firstResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
var secondResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
var firstBody = await firstResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
var secondBody = await secondResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(firstBody);
Assert.NotNull(secondBody);
var firstRow = Assert.Single(firstBody!.Quests, static r => r.QuestId == ChainQuestId);
var secondRow = Assert.Single(secondBody!.Quests, static r => r.QuestId == ChainQuestId);
AssertOperatorChainCompletionRewardSummary(firstRow.CompletionRewardSummary);
AssertCompletionRewardSummariesEqual(firstRow.CompletionRewardSummary, secondRow.CompletionRewardSummary);
}
[Fact]
public async Task GetQuestProgress_ShouldOmitCompletionRewardSummary_WhenCompletedWithoutDeliveryRecord()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var client = factory.CreateClient();
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(deps.ProgressStore.TryMarkComplete(PlayerId, GatherQuestId, DateTimeOffset.UtcNow, out _));
Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
// Act
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(body);
var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId);
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.NotNull(row.CompletedAt);
Assert.Null(row.CompletionRewardSummary);
}
[Fact]
@ -240,7 +156,6 @@ public sealed class QuestProgressApiTests
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(3, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
AssertOperatorChainCompletionRewardSummary(row.CompletionRewardSummary);
}
[Fact]
@ -276,54 +191,12 @@ public sealed class QuestProgressApiTests
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(3, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
AssertOperatorChainCompletionRewardSummary(row.CompletionRewardSummary);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var afterGet));
Assert.Equal(QuestProgressStatus.Completed, afterGet.Status);
Assert.Equal(3, afterGet.CurrentStepIndex);
Assert.NotNull(afterGet.CompletedAt);
}
private static void AssertGatherIntroCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary)
{
Assert.NotNull(summary);
Assert.Empty(summary!.ItemGrants);
Assert.Single(summary.SkillXpGrants);
Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId);
Assert.Equal(25, summary.SkillXpGrants[0].Amount);
}
private static void AssertOperatorChainCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary)
{
Assert.NotNull(summary);
Assert.Single(summary!.ItemGrants);
Assert.Equal("survey_drone_kit", summary.ItemGrants[0].ItemId);
Assert.Equal(1, summary.ItemGrants[0].Quantity);
Assert.Single(summary.SkillXpGrants);
Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId);
Assert.Equal(50, summary.SkillXpGrants[0].Amount);
}
private static void AssertCompletionRewardSummariesEqual(
QuestCompletionRewardSummaryJson? first,
QuestCompletionRewardSummaryJson? second)
{
Assert.NotNull(first);
Assert.NotNull(second);
Assert.Equal(first!.ItemGrants.Count, second!.ItemGrants.Count);
for (var i = 0; i < first.ItemGrants.Count; i++)
{
Assert.Equal(first.ItemGrants[i].ItemId, second.ItemGrants[i].ItemId);
Assert.Equal(first.ItemGrants[i].Quantity, second.ItemGrants[i].Quantity);
}
Assert.Equal(first.SkillXpGrants.Count, second.SkillXpGrants.Count);
for (var i = 0; i < first.SkillXpGrants.Count; i++)
{
Assert.Equal(first.SkillXpGrants[i].SkillId, second.SkillXpGrants[i].SkillId);
Assert.Equal(first.SkillXpGrants[i].Amount, second.SkillXpGrants[i].Amount);
}
}
private static void CompleteOperatorChain(QuestWiringTestDependencies deps)
{
AcceptAndMarkComplete(deps, GatherQuestId);
@ -357,12 +230,6 @@ public sealed class QuestProgressApiTests
deps.InventoryStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
Assert.True(result.Success);
}
@ -394,34 +261,17 @@ public sealed class QuestProgressApiTests
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
{
Assert.True(TryAccept(deps, questId).Success);
Assert.True(TryMarkComplete(deps, questId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider).Success);
}
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
@ -438,8 +288,6 @@ public sealed class QuestProgressApiTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
@ -456,8 +304,6 @@ public sealed class QuestProgressApiTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
@ -491,8 +337,6 @@ public sealed class QuestProgressApiTests
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
TimeProvider.System);
}
@ -513,7 +357,5 @@ public sealed class QuestProgressApiTests
IEncounterProgressStore EncounterProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
TimeProvider TimeProvider);
}

View File

@ -1,10 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -62,7 +59,12 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
// Act
var result = TryAccept(deps, RefineQuestId);
@ -114,7 +116,12 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
// Act
var result = TryAccept(deps, GatherQuestId);
@ -223,7 +230,12 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var result = TryMarkComplete(deps, "prototype_quest_missing");
var result = QuestStateOperations.TryMarkComplete(
PlayerId,
"prototype_quest_missing",
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
@ -253,7 +265,12 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
// Act
var result = TryAdvanceStep(deps, GatherQuestId, 1);
@ -263,114 +280,28 @@ public sealed class QuestStateOperationsTests
Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode);
}
[Fact]
public async Task TryMarkComplete_ShouldDeliverGatherIntroBundle_WhenFirstCompletion()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(TryAccept(deps, GatherQuestId).Success);
// Act
var result = TryMarkComplete(deps, GatherQuestId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent));
Assert.Equal(25, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId);
Assert.Equal(25, deliveryEvent.GrantedSkillXp[0].Amount);
}
[Fact]
public async Task TryMarkComplete_ShouldNotDoubleGrant_WhenIdempotentReplay()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var firstEvent));
// Act
var replay = TryMarkComplete(deps, GatherQuestId);
// Assert
Assert.True(replay.Success);
Assert.Equal(25, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var replayEvent));
Assert.Equal(firstEvent.IdempotencyKey, replayEvent.IdempotencyKey);
Assert.Equal(firstEvent.DeliveredAt, replayEvent.DeliveredAt);
}
[Fact]
public async Task TryMarkComplete_ShouldDeliverOperatorChainBundle_WhenFirstCompletion()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
AcceptAndComplete(deps, GatherQuestId);
AcceptAndComplete(deps, RefineQuestId);
AcceptAndComplete(deps, CombatQuestId);
Assert.True(TryAccept(deps, ChainQuestId).Success);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = TryMarkComplete(deps, ChainQuestId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out var deliveryEvent));
Assert.Single(deliveryEvent.GrantedItems);
Assert.Equal("survey_drone_kit", deliveryEvent.GrantedItems[0].ItemId);
Assert.Equal(1, deliveryEvent.GrantedItems[0].Quantity);
Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId);
Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(CountBagItem(beforeInventory!, "survey_drone_kit") + 1, CountBagItem(afterInventory!, "survey_drone_kit"));
Assert.Equal(100, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
}
[Fact]
public async Task TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
AcceptAndComplete(deps, GatherQuestId);
AcceptAndComplete(deps, RefineQuestId);
AcceptAndComplete(deps, CombatQuestId);
Assert.True(TryAccept(deps, ChainQuestId).Success);
FillBag(deps);
// Act
var result = TryMarkComplete(deps, ChainQuestId);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Active, progress.Status);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out _));
}
[Fact]
public async Task TryMarkComplete_ShouldBeIdempotentAtOperationsLayer()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success);
// Act
var first = TryMarkComplete(deps, GatherQuestId);
var second = TryMarkComplete(deps, GatherQuestId);
var first = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
var second = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
// Assert
Assert.True(first.Success);
@ -388,9 +319,15 @@ public sealed class QuestStateOperationsTests
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
// Act
var result = TryMarkComplete(deps, GatherQuestId);
var result = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
// Assert
Assert.False(result.Success);
@ -406,7 +343,12 @@ public sealed class QuestStateOperationsTests
// Act
var accept = TryAccept(deps, GatherQuestId);
var complete = TryMarkComplete(deps, GatherQuestId);
var complete = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
Assert.True(accept.Success);
@ -438,55 +380,16 @@ public sealed class QuestStateOperationsTests
Assert.True(canWrite);
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static void FillBag(QuestTestDependencies deps)
{
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
PlayerId,
"survey_drone_kit",
quantity: 1,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
}
}
private static void AcceptAndComplete(QuestTestDependencies deps, string questId)
{
Assert.True(TryAccept(deps, questId).Success);
Assert.True(TryMarkComplete(deps, questId).Success);
}
private static QuestStateOperationResult TryMarkComplete(QuestTestDependencies deps, string questId) =>
QuestStateOperations.TryMarkComplete(
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
deps.TimeProvider).Success);
}
private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) =>
TryAccept(deps, PlayerId, questId);
@ -499,12 +402,6 @@ public sealed class QuestStateOperationsTests
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
@ -516,12 +413,6 @@ public sealed class QuestStateOperationsTests
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
@ -532,12 +423,6 @@ public sealed class QuestStateOperationsTests
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
TimeProvider.System);
}
@ -546,11 +431,5 @@ public sealed class QuestStateOperationsTests
IPlayerQuestStateStore ProgressStore,
IPlayerInventoryStore InventoryStore,
IItemDefinitionRegistry ItemRegistry,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
TimeProvider TimeProvider);
}

View File

@ -3,12 +3,9 @@ using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Game.World;
@ -84,12 +81,6 @@ public static class AbilityCastApi
IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
{
@ -291,12 +282,6 @@ public static class AbilityCastApi
inventoryStore,
questRegistry,
questProgressStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));

View File

@ -1,7 +1,6 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Crafting;
@ -31,8 +30,6 @@ public static class CraftOperations
PerkUnlockEngine perkUnlockEngine,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
if (quantity <= 0)
@ -168,12 +165,6 @@ public static class CraftOperations
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
xpStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
return new CraftResult(

View File

@ -2,7 +2,6 @@ using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Crafting;
@ -19,8 +18,7 @@ public static class PlayerCraftApi
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) =>
{
if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion)
{
@ -52,8 +50,6 @@ public static class PlayerCraftApi
perkUnlockEngine,
questRegistry,
progressStore,
perkStore,
deliveryStore,
timeProvider);
if (!result.Success &&

View File

@ -1,8 +1,5 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Encounters;
@ -29,12 +26,6 @@ public static class EncounterCombatWiring
IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider,
ILogger? logger = null)
{
@ -93,12 +84,6 @@ public static class EncounterCombatWiring
inventoryStore,
questRegistry,
questProgressStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
if (!completion.Success)

View File

@ -1,8 +1,5 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Encounters;
@ -32,12 +29,6 @@ public static class EncounterCompletionOperations
IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
@ -154,12 +145,6 @@ public static class EncounterCompletionOperations
questProgressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
return new EncounterCompletionResult(

View File

@ -1,7 +1,6 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Gathering;
@ -32,8 +31,6 @@ public static class GatherOperations
IResourceNodeInstanceStore instanceStore,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
var key = ResourceNodeInstanceIds.Normalize(interactableId);
@ -143,12 +140,6 @@ public static class GatherOperations
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
xpStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
return new GatherResult(

View File

@ -3,7 +3,6 @@ using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World;
@ -24,8 +23,7 @@ public static class InteractionApi
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine,
IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) =>
{
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
{
@ -93,8 +91,6 @@ public static class InteractionApi
nodeInstanceStore,
questRegistry,
progressStore,
perkStore,
deliveryStore,
timeProvider);
if (!gather.Success)

View File

@ -1,8 +1,5 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
@ -16,9 +13,7 @@ public static class QuestAcceptApi
(string id, string questId, QuestAcceptRequest? body, IPositionStateStore positions,
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
TimeProvider timeProvider) =>
{
if (body is not null &&
body.SchemaVersion != 0 &&
@ -40,29 +35,20 @@ public static class QuestAcceptApi
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
return Results.Json(MapResponse(trimmedId, deliveryStore, result));
return Results.Json(MapResponse(result));
});
return app;
}
internal static QuestAcceptResponse MapResponse(
string playerId,
IRewardDeliveryStore deliveryStore,
QuestStateOperationResult result)
internal static QuestAcceptResponse MapResponse(QuestStateOperationResult result)
{
QuestProgressRowJson? quest = null;
if (result.Snapshot is { } snapshot)
{
quest = QuestProgressApi.MapQuestProgressRow(playerId, snapshot, deliveryStore);
quest = QuestProgressApi.MapQuestProgressRow(snapshot);
}
return new QuestAcceptResponse

View File

@ -1,7 +1,4 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
@ -22,12 +19,6 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider,
ILogger? logger = null)
{
@ -68,12 +59,6 @@ public static class QuestObjectiveWiring
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
}
catch (Exception ex)
@ -91,12 +76,6 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider,
ILogger? logger = null)
{
@ -137,12 +116,6 @@ public static class QuestObjectiveWiring
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
}
catch (Exception ex)
@ -159,12 +132,6 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider,
ILogger? logger = null)
{
@ -204,12 +171,6 @@ public static class QuestObjectiveWiring
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
}
catch (Exception ex)
@ -248,12 +209,6 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider,
ILogger? logger = null)
{
@ -272,12 +227,6 @@ public static class QuestObjectiveWiring
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
}
catch (Exception ex)
@ -337,12 +286,6 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
for (var iteration = 0; iteration < MaxStepCompletionIterations; iteration++)
@ -373,14 +316,6 @@ public static class QuestObjectiveWiring
questId,
questRegistry,
progressStore,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
return;
}
@ -393,12 +328,6 @@ public static class QuestObjectiveWiring
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
if (!advance.Success)
@ -414,12 +343,6 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
@ -433,12 +356,6 @@ public static class QuestObjectiveWiring
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
}
}

View File

@ -1,8 +1,5 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
@ -19,10 +16,7 @@ public static class QuestProgressApi
"/game/players/{id}/quest-progress",
(string id, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger<Program>? logger) =>
IItemDefinitionRegistry itemRegistry, TimeProvider timeProvider, ILogger<Program>? logger) =>
{
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
@ -36,16 +30,10 @@ public static class QuestProgressApi
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider,
logger);
return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore, deliveryStore));
return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore));
});
return app;
@ -54,14 +42,13 @@ public static class QuestProgressApi
internal static QuestProgressListResponse BuildSnapshot(
string playerId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IRewardDeliveryStore deliveryStore)
IPlayerQuestStateStore progressStore)
{
var defs = questRegistry.GetDefinitionsInIdOrder();
var rows = new List<QuestProgressRowJson>(defs.Count);
foreach (var def in defs)
{
rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore, deliveryStore));
rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore));
}
return new QuestProgressListResponse
@ -75,21 +62,17 @@ public static class QuestProgressApi
internal static QuestProgressRowJson MapQuestProgressRow(
string playerId,
string questId,
IPlayerQuestStateStore progressStore,
IRewardDeliveryStore deliveryStore)
IPlayerQuestStateStore progressStore)
{
if (!progressStore.TryGetProgress(playerId, questId, out var snapshot))
{
return NotStartedRow(questId);
}
return MapQuestProgressRow(playerId, snapshot, deliveryStore);
return MapQuestProgressRow(snapshot);
}
internal static QuestProgressRowJson MapQuestProgressRow(
string playerId,
QuestStepState snapshot,
IRewardDeliveryStore deliveryStore)
internal static QuestProgressRowJson MapQuestProgressRow(QuestStepState snapshot)
{
var counters = new Dictionary<string, int>(snapshot.ObjectiveCounters, StringComparer.Ordinal);
return snapshot.Status switch
@ -108,63 +91,11 @@ public static class QuestProgressApi
CurrentStepIndex = snapshot.CurrentStepIndex,
ObjectiveCounters = counters,
CompletedAt = snapshot.CompletedAt,
CompletionRewardSummary = MapCompletionRewardSummary(playerId, snapshot.QuestId, deliveryStore),
},
_ => throw new InvalidOperationException($"Unknown quest progress status '{snapshot.Status}'."),
};
}
private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary(
string playerId,
string questId,
IRewardDeliveryStore deliveryStore)
{
if (!deliveryStore.TryGet(playerId, questId, out var deliveryEvent))
{
return null;
}
return new QuestCompletionRewardSummaryJson
{
ItemGrants = MapItemGrants(deliveryEvent.GrantedItems),
SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp),
};
}
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedItems"/>.</summary>
private static List<QuestItemGrantJson> MapItemGrants(IReadOnlyList<RewardItemGrantApplied> grants)
{
var summary = new List<QuestItemGrantJson>(grants.Count);
foreach (var grant in grants)
{
summary.Add(
new QuestItemGrantJson
{
ItemId = grant.ItemId,
Quantity = grant.Quantity,
});
}
return summary;
}
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedSkillXp"/>.</summary>
private static List<QuestSkillXpGrantJson> MapSkillXpGrants(IReadOnlyList<RewardSkillXpGrantApplied> grants)
{
var summary = new List<QuestSkillXpGrantJson>(grants.Count);
foreach (var grant in grants)
{
summary.Add(
new QuestSkillXpGrantJson
{
SkillId = grant.SkillId,
Amount = grant.Amount,
});
}
return summary;
}
private static QuestProgressRowJson NotStartedRow(string questId) =>
new()
{

View File

@ -38,39 +38,4 @@ public sealed class QuestProgressRowJson
[JsonPropertyName("completedAt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>Grant snapshot when <see cref="Status"/> is <c>completed</c> and delivery was recorded (NEO-129).</summary>
[JsonPropertyName("completionRewardSummary")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; }
}
/// <summary>Item + skill XP grants applied on first-time quest completion (NEO-129).</summary>
public sealed class QuestCompletionRewardSummaryJson
{
[JsonPropertyName("itemGrants")]
public required IReadOnlyList<QuestItemGrantJson> ItemGrants { get; init; }
[JsonPropertyName("skillXpGrants")]
public required IReadOnlyList<QuestSkillXpGrantJson> SkillXpGrants { get; init; }
}
/// <summary>One item grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary>
public sealed class QuestItemGrantJson
{
[JsonPropertyName("itemId")]
public required string ItemId { get; init; }
[JsonPropertyName("quantity")]
public required int Quantity { get; init; }
}
/// <summary>One skill XP grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary>
public sealed class QuestSkillXpGrantJson
{
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("amount")]
public required int Amount { get; init; }
}

View File

@ -1,7 +1,4 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
@ -23,12 +20,6 @@ public static class QuestStateOperations
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -82,12 +73,6 @@ public static class QuestStateOperations
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
@ -112,12 +97,6 @@ public static class QuestStateOperations
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -167,12 +146,6 @@ public static class QuestStateOperations
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
@ -187,26 +160,17 @@ public static class QuestStateOperations
}
/// <summary>
/// Delivers <see cref="QuestDefRow.CompletionRewardBundle"/> then marks complete (NEO-128).
/// Idempotent: replays return success with the existing snapshot without re-delivering.
/// Marks a quest complete. Idempotent: replays return success with the existing snapshot.
/// </summary>
public static QuestStateOperationResult TryMarkComplete(
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
!questRegistry.TryGetDefinition(normalizedQuestId, out _))
{
return Deny(QuestStateReasonCodes.UnknownQuest);
}
@ -226,25 +190,6 @@ public static class QuestStateOperations
return Deny(QuestStateReasonCodes.NotActive);
}
var delivery = RewardRouterOperations.TryDeliverQuestCompletion(
playerId,
normalizedQuestId,
definition.CompletionRewardBundle,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
if (!delivery.Success)
{
return Deny(delivery.ReasonCode ?? RewardDeliveryReasonCodes.InvalidQuestId, progress);
}
if (progressStore.TryMarkComplete(playerId, normalizedQuestId, timeProvider.GetUtcNow(), out var snapshot))
{
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_complete` ---

View File

@ -3,7 +3,6 @@ namespace NeonSprawl.Server.Game.Rewards;
/// <summary>
/// Idempotent quest completion reward delivery record (NEO-126).
/// Persisted via <see cref="IRewardDeliveryStore"/> after router apply succeeds (NEO-127).
/// E9.M1 <c>reward_delivery</c> hook site: <see cref="RewardRouterOperations"/> <c>TryDeliverQuestCompletion</c> (NEO-130).
/// </summary>
public readonly record struct RewardDeliveryEvent(
string PlayerId,

View File

@ -9,7 +9,6 @@ namespace NeonSprawl.Server.Game.Rewards;
/// <summary>
/// Applies quest <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127).
/// Quest-state wiring: <see cref="QuestStateOperations"/> (NEO-128).
/// E9.M1 telemetry hook sites: <see cref="TryDeliverQuestCompletion"/> (NEO-130).
/// </summary>
public static class RewardRouterOperations
{
@ -135,14 +134,6 @@ public static class RewardRouterOperations
perksUnlockedByThisCall);
}
// --- Telemetry hook site (NEO-130): future E9.M1 catalog event `unlock_granted` (stub) ---
// TODO(E9.M1): catalog emit — once per applied UnlockGrant row when bundle unlock grants land (pre-production).
// Prototype completionRewardBundle has item + skill XP rows only; no runtime unlock apply in Slice 2.
// Planned payload fields: playerId, questId, unlock id / kind — finalize at E9.M1 wiring time.
// Distinct from `perk_unlock` (PerkUnlockEngine, NEO-49) which may fire as a side effect of skill XP grants.
// E9.M1 wiring: place per-row emits inside the apply loop and/or after TryRecord success only — not on
// TryRecord race-rollback paths (grants applied then reverted below); mirror reward_delivery gating.
var deliveredAt = timeProvider.GetUtcNow();
var deliveryEvent = new RewardDeliveryEvent(
normalizedPlayerId,
@ -154,13 +145,6 @@ public static class RewardRouterOperations
if (deliveryStore.TryRecord(deliveryEvent))
{
// --- Telemetry hook site (NEO-130): future E9.M1 catalog event `reward_delivery` ---
// TODO(E9.M1): catalog emit — once per first-time quest completion delivery (TryRecord success).
// Not on TryGet idempotent replay, deny paths, or TryRecord race-loser rollback paths.
// Planned payload fields: playerId, questId, deliveredAt, grantedItems (itemId, quantity),
// grantedSkillXp (skillId, amount), idempotencyKey ({playerId}:quest_complete:{questId}).
// No ingest or ILogger here (comments-only).
return SuccessFromEvent(deliveryEvent, reasonCode: null);
}

View File

@ -183,7 +183,7 @@ Completed rows cannot regress to active without a reset API (none in prototype).
|--------|------|
| **`TryAccept`** | Validates quest id + **`prerequisiteQuestIds`** (each prerequisite must be **completed**), then activates at step 0. |
| **`TryAdvanceStep`** | Requires active row; **`newStepIndex`** must be greater than current and less than step count from **`QuestDefRow`**. |
| **`TryMarkComplete`** | **NEO-128:** delivers **`completionRewardBundle`** via **`RewardRouterOperations`** (deliver-then-mark), then marks active quest complete. **Idempotent:** when already **completed**, returns **`success: true`** without calling the router; replay after first-time complete also skips re-delivery. On delivery deny, quest stays **active** and **`reasonCode`** passthroughs router codes (e.g. **`inventory_full`**). |
| **`TryMarkComplete`** | Marks active quest complete. **Idempotent:** second call returns **`success: true`** with unchanged snapshot (unlike encounter completion). |
**Reason codes:**
@ -212,14 +212,7 @@ Completed rows cannot regress to active without a reset API (none in prototype).
### Per-player quest progress (NEO-119)
**`GET /game/players/{id}/quest-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`quests`**) backed by **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`**. Unknown or blank player ids return **404** (position gate, same as encounter-progress). Each row includes **`questId`**, **`status`** (`not_started` | `active` | `completed`), **`currentStepIndex`**, **`objectiveCounters`** (objective id → count for the current step only), optional **`completedAt`** when **`completed`**, and optional **`completionRewardSummary`** when **`completed`** and a delivery event exists in **`IRewardDeliveryStore`**. Quests are ordered by catalog **`id`** (ordinal).
| Field | When present |
|-------|----------------|
| **`completedAt`** | **`status: completed`** |
| **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`) and **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`). Omitted when not completed or when no delivery record exists. |
Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`** and empty **`itemGrants`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`.
**`GET /game/players/{id}/quest-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`quests`**) backed by **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`**. Unknown or blank player ids return **404** (position gate, same as encounter-progress). Each row includes **`questId`**, **`status`** (`not_started` | `active` | `completed`), **`currentStepIndex`**, **`objectiveCounters`** (objective id → count for the current step only), and optional **`completedAt`** when **`completed`**. Quests are ordered by catalog **`id`** (ordinal).
Before building the snapshot, the handler best-effort runs **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** — refreshes **`inventory_has_item`** counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: [NEO-119 implementation plan](../../docs/plans/NEO-119-implementation-plan.md).
@ -304,18 +297,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`** — negative XP delta per applied skill row plus removal of perks unlocked during this call only — then **`ReevaluateAfterLevelUp`** at post-rollback levels so a concurrent winner's path-auto unlocks are preserved — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call this operation before **`IPlayerQuestStateStore.TryMarkComplete`**. Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md).
### Reward telemetry hooks (NEO-130)
Comment-only hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** for future E9.M1 catalog events ([Epic 7 Slice 2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md#related-implementation-slices)). **`TODO(E9.M1)`** — no production ingest. **`QuestStateOperations`** and HTTP quest routes delegate to the router only (no duplicate API-layer or quest-state hooks for these names). **`quest_complete`** remains in **`QuestStateOperations`** ([NEO-121](#quest-telemetry-hooks-neo-121)); **`reward_delivery`** fires earlier on the deliver-then-mark path (NEO-128).
| Event | Anchor | When |
|-------|--------|------|
| **`reward_delivery`** | **`TryDeliverQuestCompletion`** | After **`TryRecord`** returns **`true`** on first-time delivery (not idempotent **`TryGet`** replay or race-loser paths). |
| **`unlock_granted`** | **`TryDeliverQuestCompletion`** (stub) | Future **`UnlockGrant`** apply from bundle rows — prototype bundles have item + skill XP only; no runtime unlock apply in Slice 2. |
**`perk_unlock`** side effects from skill XP grants use **`PerkUnlockEngine`** ([NEO-49](#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49)) — distinct from content **`UnlockGrant`** rows. Manual QA: [`docs/manual-qa/NEO-130.md`](../../docs/manual-qa/NEO-130.md); plan: [NEO-130 implementation plan](../../docs/plans/NEO-130-implementation-plan.md).
Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`** — negative XP delta per applied skill row plus removal of perks unlocked during this call only — then **`ReevaluateAfterLevelUp`** at post-rollback levels so a concurrent winner's path-auto unlocks are preserved — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). Quest-state wiring lands in NEO-128. Plan: [NEO-127 implementation plan](../../docs/plans/NEO-127-implementation-plan.md).
## Encounter definitions (NEO-103)