Compare commits
25 Commits
4e5d92b6c8
...
bc2e9d6045
| Author | SHA1 | Date |
|---|---|---|
|
|
bc2e9d6045 | |
|
|
3c3170c731 | |
|
|
dc39250e96 | |
|
|
7e3507088b | |
|
|
0e13a10b79 | |
|
|
ca9a3f1b38 | |
|
|
ed7975ca54 | |
|
|
1a2478b289 | |
|
|
5578de9de3 | |
|
|
a7579776a6 | |
|
|
1333da2938 | |
|
|
471d4b61a7 | |
|
|
c461a822f1 | |
|
|
7493a6b078 | |
|
|
9b2c209096 | |
|
|
5126e82c4a | |
|
|
9ab3a70796 | |
|
|
185b38b242 | |
|
|
78e805e428 | |
|
|
2b06eb2a1e | |
|
|
c1849740b2 | |
|
|
28e4a70846 | |
|
|
f3b4438968 | |
|
|
da99769ddf | |
|
|
7abe7dcafd |
|
|
@ -0,0 +1,133 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ tests {
|
|||
expect(row.currentStepIndex).to.equal(0);
|
||||
expect(row.objectiveCounters).to.eql({});
|
||||
expect(row.completedAt).to.equal(undefined);
|
||||
expect(row.completionRewardSummary).to.equal(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
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");
|
||||
});
|
||||
}
|
||||
|
|
@ -239,6 +239,18 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1260,6 +1260,17 @@ 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
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ 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")
|
||||
|
|
@ -862,7 +863,9 @@ func _setup_quest_progress_sync() -> void:
|
|||
_quest_defs_client,
|
||||
_quest_progress_label,
|
||||
_quest_accept_feedback_label,
|
||||
Callable(self, "_apply_authority_http_config_to_client")
|
||||
Callable(self, "_apply_authority_http_config_to_client"),
|
||||
_quest_reward_delivery_label,
|
||||
_item_defs_client
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,28 @@
|
|||
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(
|
||||
|
|
@ -22,12 +30,16 @@ func setup(
|
|||
defs_client: Node,
|
||||
progress_label: Label,
|
||||
accept_label: Label,
|
||||
apply_http_config: Callable
|
||||
apply_http_config: Callable,
|
||||
reward_label: Label = null,
|
||||
item_defs_client: Node = null
|
||||
) -> 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)
|
||||
|
|
@ -47,8 +59,11 @@ 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()
|
||||
|
|
@ -78,9 +93,11 @@ 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:
|
||||
|
|
@ -90,10 +107,12 @@ 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:
|
||||
|
|
@ -120,6 +139,8 @@ 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:
|
||||
|
|
@ -127,6 +148,87 @@ 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
|
||||
|
|
@ -169,6 +271,65 @@ 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:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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)
|
||||
|
|
@ -77,6 +78,23 @@ 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:
|
||||
|
|
|
|||
|
|
@ -75,6 +75,17 @@ 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":'
|
||||
|
|
@ -198,6 +209,27 @@ 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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
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—")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://d4n7q1rewardhudtest01
|
||||
|
|
@ -71,3 +71,11 @@ 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
|
|
@ -0,0 +1,33 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# 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.
|
||||
|
|
@ -194,8 +194,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Completing a quest via objective wiring delivers bundle once.
|
||||
- [ ] Idempotent complete does not double-grant items or XP.
|
||||
- [x] Completing a quest via objective wiring delivers bundle once.
|
||||
- [x] 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**
|
||||
|
||||
- [ ] Completed quest rows include summary when delivery recorded.
|
||||
- [ ] `not_started` / `active` rows omit summary.
|
||||
- [x] Completed quest rows include summary when delivery recorded.
|
||||
- [x] `not_started` / `active` rows omit summary.
|
||||
|
||||
**Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131).
|
||||
|
||||
|
|
@ -241,7 +241,9 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook sites documented in README; no behavior change beyond comments.
|
||||
- [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).
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
|
|
@ -265,8 +267,10 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Completing a quest shows readable reward copy on HUD.
|
||||
- [ ] Failed GET surfaces visible error (NEO-122 pattern).
|
||||
- [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).
|
||||
|
||||
**Server counterpart:** [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
# 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** |
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
# 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** |
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
# 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** |
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# 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`**).
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# 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.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# 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-124–NEO-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.
|
||||
|
|
@ -3,6 +3,7 @@ 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;
|
||||
|
|
@ -35,6 +36,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -79,6 +82,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -114,6 +119,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -151,6 +158,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -185,6 +194,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -220,6 +231,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -252,6 +265,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -280,6 +295,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -311,6 +328,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -344,6 +363,8 @@ public sealed class CraftOperationsTests
|
|||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -392,7 +413,9 @@ public sealed class CraftOperationsTests
|
|||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>());
|
||||
services.GetRequiredService<IPlayerQuestStateStore>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>());
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
|
|
@ -429,7 +452,9 @@ public sealed class CraftOperationsTests
|
|||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore ProgressStore);
|
||||
IPlayerQuestStateStore ProgressStore,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore);
|
||||
|
||||
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
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;
|
||||
|
||||
|
|
@ -37,6 +40,12 @@ 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
|
||||
|
|
@ -68,6 +77,12 @@ 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
|
||||
|
|
@ -103,6 +118,12 @@ 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
|
||||
|
|
@ -136,6 +157,12 @@ 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
|
||||
|
|
@ -162,6 +189,12 @@ 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);
|
||||
|
||||
|
|
@ -180,6 +213,12 @@ 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
|
||||
|
|
@ -212,6 +251,12 @@ 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
|
||||
|
|
@ -253,6 +298,12 @@ 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
|
||||
|
|
@ -291,6 +342,12 @@ 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
|
||||
|
|
@ -313,6 +370,12 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -363,7 +426,13 @@ public sealed class EncounterCombatWiringTests
|
|||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>());
|
||||
services.GetRequiredService<IPlayerQuestStateStore>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>());
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
|
|
@ -389,5 +458,11 @@ public sealed class EncounterCombatWiringTests
|
|||
IItemDefinitionRegistry ItemRegistry,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore QuestProgressStore);
|
||||
IPlayerQuestStateStore QuestProgressStore,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IPlayerSkillProgressionStore SkillProgressionStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
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;
|
||||
|
||||
|
|
@ -37,6 +40,12 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
timeProvider);
|
||||
|
||||
// Assert
|
||||
|
|
@ -80,6 +89,12 @@ 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);
|
||||
|
||||
|
|
@ -96,6 +111,12 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
timeProvider);
|
||||
|
||||
// Assert
|
||||
|
|
@ -154,6 +175,12 @@ 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
|
||||
|
|
@ -188,6 +215,12 @@ 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
|
||||
|
|
@ -224,6 +257,12 @@ 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
|
||||
|
|
@ -258,6 +297,12 @@ public sealed class EncounterCompletionOperationsTests
|
|||
inventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -290,6 +335,12 @@ 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
|
||||
|
|
@ -352,7 +403,13 @@ public sealed class EncounterCompletionOperationsTests
|
|||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>());
|
||||
services.GetRequiredService<IPlayerQuestStateStore>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>());
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
|
|
@ -392,7 +449,13 @@ public sealed class EncounterCompletionOperationsTests
|
|||
IItemDefinitionRegistry ItemRegistry,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore QuestProgressStore);
|
||||
IPlayerQuestStateStore QuestProgressStore,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IPlayerSkillProgressionStore SkillProgressionStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore);
|
||||
|
||||
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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;
|
||||
|
|
@ -36,6 +37,8 @@ public sealed class GatherOperationsTests
|
|||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
|
|
@ -90,6 +93,8 @@ public sealed class GatherOperationsTests
|
|||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
|
|
@ -126,6 +131,8 @@ public sealed class GatherOperationsTests
|
|||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
Assert.True(gather.Success);
|
||||
}
|
||||
|
|
@ -147,6 +154,8 @@ public sealed class GatherOperationsTests
|
|||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
|
|
@ -182,6 +191,8 @@ public sealed class GatherOperationsTests
|
|||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _);
|
||||
|
|
@ -215,6 +226,8 @@ public sealed class GatherOperationsTests
|
|||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
|
|
@ -249,6 +262,8 @@ public sealed class GatherOperationsTests
|
|||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
|
|
@ -273,7 +288,9 @@ public sealed class GatherOperationsTests
|
|||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IResourceNodeInstanceStore>(),
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>());
|
||||
services.GetRequiredService<IPlayerQuestStateStore>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>());
|
||||
}
|
||||
|
||||
private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
|
|
@ -311,7 +328,9 @@ public sealed class GatherOperationsTests
|
|||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IResourceNodeInstanceStore InstanceStore,
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore ProgressStore);
|
||||
IPlayerQuestStateStore ProgressStore,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore);
|
||||
|
||||
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ 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;
|
||||
|
||||
|
|
@ -73,6 +76,7 @@ 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);
|
||||
|
|
@ -202,22 +206,45 @@ public sealed class QuestAcceptApiTests
|
|||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var questRegistry = factory.Services.GetRequiredService<IQuestDefinitionRegistry>();
|
||||
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
|
||||
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 timeProvider = TimeProvider.System;
|
||||
Assert.True(QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
factory.Services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
factory.Services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
itemRegistry,
|
||||
inventoryStore,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider).Success);
|
||||
|
||||
// Act
|
||||
|
|
@ -235,6 +262,11 @@ 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]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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;
|
||||
|
|
@ -41,6 +42,37 @@ 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]
|
||||
|
|
@ -78,6 +110,12 @@ 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
|
||||
|
|
@ -110,12 +148,56 @@ 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]
|
||||
|
|
@ -147,37 +229,13 @@ 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(QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
1,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider).Success);
|
||||
Assert.True(TryAdvanceStep(deps, ChainQuestId, 1).Success);
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _));
|
||||
Assert.True(QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
2,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider).Success);
|
||||
Assert.True(TryAdvanceStep(deps, ChainQuestId, 2).Success);
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _));
|
||||
|
||||
// Act
|
||||
var advance = QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
3,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider);
|
||||
var advance = TryAdvanceStep(deps, ChainQuestId, 3);
|
||||
|
||||
// Assert
|
||||
Assert.True(advance.Success);
|
||||
|
|
@ -201,6 +259,12 @@ 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);
|
||||
}
|
||||
|
|
@ -232,17 +296,51 @@ 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(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
questId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider).Success);
|
||||
Assert.True(TryMarkComplete(deps, questId).Success);
|
||||
}
|
||||
|
||||
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
|
||||
|
|
@ -259,6 +357,8 @@ public sealed class QuestObjectiveWiringTests
|
|||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
|
||||
|
|
@ -275,8 +375,40 @@ 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(
|
||||
|
|
@ -308,6 +440,8 @@ public sealed class QuestObjectiveWiringTests
|
|||
services.GetRequiredService<IEncounterProgressStore>(),
|
||||
services.GetRequiredService<IEncounterCompletionStore>(),
|
||||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -328,5 +462,7 @@ public sealed class QuestObjectiveWiringTests
|
|||
IEncounterProgressStore EncounterProgressStore,
|
||||
IEncounterCompletionStore CompletionStore,
|
||||
IEncounterCompleteEventStore CompleteEventStore,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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;
|
||||
|
|
@ -78,6 +79,7 @@ public sealed class QuestProgressApiTests
|
|||
Assert.Equal(0, row.CurrentStepIndex);
|
||||
Assert.Empty(row.ObjectiveCounters);
|
||||
Assert.Null(row.CompletedAt);
|
||||
Assert.Null(row.CompletionRewardSummary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +110,7 @@ public sealed class QuestProgressApiTests
|
|||
Assert.Equal(0, row.CurrentStepIndex);
|
||||
Assert.Equal(2, row.ObjectiveCounters[GatherObjectiveId]);
|
||||
Assert.Null(row.CompletedAt);
|
||||
Assert.Null(row.CompletionRewardSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -134,6 +137,87 @@ 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]
|
||||
|
|
@ -156,6 +240,7 @@ public sealed class QuestProgressApiTests
|
|||
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
|
||||
Assert.Equal(3, row.CurrentStepIndex);
|
||||
Assert.NotNull(row.CompletedAt);
|
||||
AssertOperatorChainCompletionRewardSummary(row.CompletionRewardSummary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -191,12 +276,54 @@ 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);
|
||||
|
|
@ -230,6 +357,12 @@ 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);
|
||||
}
|
||||
|
|
@ -261,17 +394,34 @@ 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(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
questId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider).Success);
|
||||
Assert.True(TryMarkComplete(deps, questId).Success);
|
||||
}
|
||||
|
||||
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
|
||||
|
|
@ -288,6 +438,8 @@ public sealed class QuestProgressApiTests
|
|||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
|
||||
|
|
@ -304,6 +456,8 @@ 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)
|
||||
|
|
@ -337,6 +491,8 @@ public sealed class QuestProgressApiTests
|
|||
services.GetRequiredService<IEncounterProgressStore>(),
|
||||
services.GetRequiredService<IEncounterCompletionStore>(),
|
||||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -357,5 +513,7 @@ public sealed class QuestProgressApiTests
|
|||
IEncounterProgressStore EncounterProgressStore,
|
||||
IEncounterCompletionStore CompletionStore,
|
||||
IEncounterCompleteEventStore CompleteEventStore,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
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;
|
||||
|
||||
|
|
@ -59,12 +62,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
timeProvider).Success);
|
||||
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
|
||||
|
||||
// Act
|
||||
var result = TryAccept(deps, RefineQuestId);
|
||||
|
|
@ -116,12 +114,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
timeProvider).Success);
|
||||
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
|
||||
|
||||
// Act
|
||||
var result = TryAccept(deps, GatherQuestId);
|
||||
|
|
@ -230,12 +223,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
"prototype_quest_missing",
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
var result = TryMarkComplete(deps, "prototype_quest_missing");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -265,12 +253,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
timeProvider).Success);
|
||||
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
|
||||
|
||||
// Act
|
||||
var result = TryAdvanceStep(deps, GatherQuestId, 1);
|
||||
|
|
@ -280,28 +263,114 @@ 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 = QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
timeProvider);
|
||||
var second = QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
timeProvider);
|
||||
var first = TryMarkComplete(deps, GatherQuestId);
|
||||
var second = TryMarkComplete(deps, GatherQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.True(first.Success);
|
||||
|
|
@ -319,15 +388,9 @@ public sealed class QuestStateOperationsTests
|
|||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
timeProvider);
|
||||
var result = TryMarkComplete(deps, GatherQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -343,12 +406,7 @@ public sealed class QuestStateOperationsTests
|
|||
|
||||
// Act
|
||||
var accept = TryAccept(deps, GatherQuestId);
|
||||
var complete = QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
var complete = TryMarkComplete(deps, GatherQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.True(accept.Success);
|
||||
|
|
@ -380,16 +438,55 @@ 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(QuestStateOperations.TryMarkComplete(
|
||||
Assert.True(TryMarkComplete(deps, questId).Success);
|
||||
}
|
||||
|
||||
private static QuestStateOperationResult TryMarkComplete(QuestTestDependencies deps, string questId) =>
|
||||
QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
questId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider).Success);
|
||||
}
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) =>
|
||||
TryAccept(deps, PlayerId, questId);
|
||||
|
|
@ -402,6 +499,12 @@ 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) =>
|
||||
|
|
@ -413,6 +516,12 @@ 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)
|
||||
|
|
@ -423,6 +532,12 @@ 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);
|
||||
}
|
||||
|
||||
|
|
@ -431,5 +546,11 @@ public sealed class QuestStateOperationsTests
|
|||
IPlayerQuestStateStore ProgressStore,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IPlayerSkillProgressionStore SkillProgressionStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ 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;
|
||||
|
||||
|
|
@ -81,6 +84,12 @@ 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) =>
|
||||
{
|
||||
|
|
@ -282,6 +291,12 @@ public static class AbilityCastApi
|
|||
inventoryStore,
|
||||
questRegistry,
|
||||
questProgressStore,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
clock,
|
||||
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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;
|
||||
|
|
@ -30,6 +31,8 @@ public static class CraftOperations
|
|||
PerkUnlockEngine perkUnlockEngine,
|
||||
IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore,
|
||||
IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (quantity <= 0)
|
||||
|
|
@ -165,6 +168,12 @@ public static class CraftOperations
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
xpStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
return new CraftResult(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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;
|
||||
|
|
@ -18,7 +19,8 @@ public static class PlayerCraftApi
|
|||
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
|
||||
PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) =>
|
||||
IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion)
|
||||
{
|
||||
|
|
@ -50,6 +52,8 @@ public static class PlayerCraftApi
|
|||
perkUnlockEngine,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
if (!result.Success &&
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
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;
|
||||
|
||||
|
|
@ -26,6 +29,12 @@ 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)
|
||||
{
|
||||
|
|
@ -84,6 +93,12 @@ public static class EncounterCombatWiring
|
|||
inventoryStore,
|
||||
questRegistry,
|
||||
questProgressStore,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
if (!completion.Success)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
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,6 +32,12 @@ 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) ||
|
||||
|
|
@ -145,6 +154,12 @@ public static class EncounterCompletionOperations
|
|||
questProgressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
return new EncounterCompletionResult(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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;
|
||||
|
|
@ -31,6 +32,8 @@ public static class GatherOperations
|
|||
IResourceNodeInstanceStore instanceStore,
|
||||
IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore,
|
||||
IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var key = ResourceNodeInstanceIds.Normalize(interactableId);
|
||||
|
|
@ -140,6 +143,12 @@ public static class GatherOperations
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
xpStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
return new GatherResult(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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;
|
||||
|
||||
|
|
@ -23,7 +24,8 @@ public static class InteractionApi
|
|||
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine,
|
||||
IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) =>
|
||||
IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
|
||||
{
|
||||
|
|
@ -91,6 +93,8 @@ public static class InteractionApi
|
|||
nodeInstanceStore,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
if (!gather.Success)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
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;
|
||||
|
||||
|
|
@ -13,7 +16,9 @@ public static class QuestAcceptApi
|
|||
(string id, string questId, QuestAcceptRequest? body, IPositionStateStore positions,
|
||||
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
|
||||
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
|
||||
TimeProvider timeProvider) =>
|
||||
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
|
||||
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is not null &&
|
||||
body.SchemaVersion != 0 &&
|
||||
|
|
@ -35,20 +40,29 @@ public static class QuestAcceptApi
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
return Results.Json(MapResponse(result));
|
||||
return Results.Json(MapResponse(trimmedId, deliveryStore, result));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static QuestAcceptResponse MapResponse(QuestStateOperationResult result)
|
||||
internal static QuestAcceptResponse MapResponse(
|
||||
string playerId,
|
||||
IRewardDeliveryStore deliveryStore,
|
||||
QuestStateOperationResult result)
|
||||
{
|
||||
QuestProgressRowJson? quest = null;
|
||||
if (result.Snapshot is { } snapshot)
|
||||
{
|
||||
quest = QuestProgressApi.MapQuestProgressRow(snapshot);
|
||||
quest = QuestProgressApi.MapQuestProgressRow(playerId, snapshot, deliveryStore);
|
||||
}
|
||||
|
||||
return new QuestAcceptResponse
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
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;
|
||||
|
||||
|
|
@ -19,6 +22,12 @@ 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)
|
||||
{
|
||||
|
|
@ -59,6 +68,12 @@ public static class QuestObjectiveWiring
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -76,6 +91,12 @@ 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)
|
||||
{
|
||||
|
|
@ -116,6 +137,12 @@ public static class QuestObjectiveWiring
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -132,6 +159,12 @@ 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)
|
||||
{
|
||||
|
|
@ -171,6 +204,12 @@ public static class QuestObjectiveWiring
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -209,6 +248,12 @@ 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)
|
||||
{
|
||||
|
|
@ -227,6 +272,12 @@ public static class QuestObjectiveWiring
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -286,6 +337,12 @@ 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++)
|
||||
|
|
@ -316,6 +373,14 @@ public static class QuestObjectiveWiring
|
|||
questId,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
itemRegistry,
|
||||
inventoryStore,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
return;
|
||||
}
|
||||
|
|
@ -328,6 +393,12 @@ public static class QuestObjectiveWiring
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
if (!advance.Success)
|
||||
|
|
@ -343,6 +414,12 @@ 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())
|
||||
|
|
@ -356,6 +433,12 @@ public static class QuestObjectiveWiring
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
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,7 +19,10 @@ public static class QuestProgressApi
|
|||
"/game/players/{id}/quest-progress",
|
||||
(string id, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore,
|
||||
IItemDefinitionRegistry itemRegistry, TimeProvider timeProvider, ILogger<Program>? logger) =>
|
||||
IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve,
|
||||
PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger<Program>? logger) =>
|
||||
{
|
||||
var trimmedId = id.Trim();
|
||||
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||
|
|
@ -30,10 +36,16 @@ public static class QuestProgressApi
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider,
|
||||
logger);
|
||||
|
||||
return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore));
|
||||
return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore, deliveryStore));
|
||||
});
|
||||
|
||||
return app;
|
||||
|
|
@ -42,13 +54,14 @@ public static class QuestProgressApi
|
|||
internal static QuestProgressListResponse BuildSnapshot(
|
||||
string playerId,
|
||||
IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore)
|
||||
IPlayerQuestStateStore progressStore,
|
||||
IRewardDeliveryStore deliveryStore)
|
||||
{
|
||||
var defs = questRegistry.GetDefinitionsInIdOrder();
|
||||
var rows = new List<QuestProgressRowJson>(defs.Count);
|
||||
foreach (var def in defs)
|
||||
{
|
||||
rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore));
|
||||
rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore, deliveryStore));
|
||||
}
|
||||
|
||||
return new QuestProgressListResponse
|
||||
|
|
@ -62,17 +75,21 @@ public static class QuestProgressApi
|
|||
internal static QuestProgressRowJson MapQuestProgressRow(
|
||||
string playerId,
|
||||
string questId,
|
||||
IPlayerQuestStateStore progressStore)
|
||||
IPlayerQuestStateStore progressStore,
|
||||
IRewardDeliveryStore deliveryStore)
|
||||
{
|
||||
if (!progressStore.TryGetProgress(playerId, questId, out var snapshot))
|
||||
{
|
||||
return NotStartedRow(questId);
|
||||
}
|
||||
|
||||
return MapQuestProgressRow(snapshot);
|
||||
return MapQuestProgressRow(playerId, snapshot, deliveryStore);
|
||||
}
|
||||
|
||||
internal static QuestProgressRowJson MapQuestProgressRow(QuestStepState snapshot)
|
||||
internal static QuestProgressRowJson MapQuestProgressRow(
|
||||
string playerId,
|
||||
QuestStepState snapshot,
|
||||
IRewardDeliveryStore deliveryStore)
|
||||
{
|
||||
var counters = new Dictionary<string, int>(snapshot.ObjectiveCounters, StringComparer.Ordinal);
|
||||
return snapshot.Status switch
|
||||
|
|
@ -91,11 +108,63 @@ 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -38,4 +38,39 @@ 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
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;
|
||||
|
||||
|
|
@ -20,6 +23,12 @@ 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) ||
|
||||
|
|
@ -73,6 +82,12 @@ public static class QuestStateOperations
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
|
||||
|
|
@ -97,6 +112,12 @@ 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) ||
|
||||
|
|
@ -146,6 +167,12 @@ public static class QuestStateOperations
|
|||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
perkStore,
|
||||
deliveryStore,
|
||||
timeProvider);
|
||||
|
||||
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
|
||||
|
|
@ -160,17 +187,26 @@ public static class QuestStateOperations
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a quest complete. Idempotent: replays return success with the existing snapshot.
|
||||
/// Delivers <see cref="QuestDefRow.CompletionRewardBundle"/> then marks complete (NEO-128).
|
||||
/// Idempotent: replays return success with the existing snapshot without re-delivering.
|
||||
/// </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 _))
|
||||
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
|
||||
{
|
||||
return Deny(QuestStateReasonCodes.UnknownQuest);
|
||||
}
|
||||
|
|
@ -190,6 +226,25 @@ 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` ---
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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
|
||||
{
|
||||
|
|
@ -134,6 +135,14 @@ 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,
|
||||
|
|
@ -145,6 +154,13 @@ 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`** | Marks active quest complete. **Idempotent:** second call returns **`success: true`** with unchanged snapshot (unlike encounter completion). |
|
||||
| **`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`**). |
|
||||
|
||||
**Reason codes:**
|
||||
|
||||
|
|
@ -212,7 +212,14 @@ 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), and optional **`completedAt`** when **`completed`**. Quests are ordered by catalog **`id`** (ordinal).
|
||||
**`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`.
|
||||
|
||||
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).
|
||||
|
||||
|
|
@ -297,7 +304,18 @@ 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 lands in NEO-128. Plan: [NEO-127 implementation plan](../../docs/plans/NEO-127-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 (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).
|
||||
|
||||
## Encounter definitions (NEO-103)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue