Merge pull request #159 from ViPro-Technologies/NEO-120-quest-accept-post
NEO-120: POST /game/players/{id}/quests/{questId}/accept
pull/160/head
commit
6af9f42df8
|
|
@ -0,0 +1,47 @@
|
|||
meta {
|
||||
name: Accept duplicate
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-120: second accept on an active quest returns already_active with quest snapshot (no mutation).
|
||||
Pre-request ensures gather intro is active (idempotent when seq 3 already ran).
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/quests/prototype_quest_gather_intro/accept`,
|
||||
{ schemaVersion: 1 },
|
||||
jsonHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_gather_intro/accept
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("duplicate accept returns already_active with active quest row", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.accepted).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("already_active");
|
||||
expect(body.quest).to.be.an("object");
|
||||
expect(body.quest.questId).to.equal("prototype_quest_gather_intro");
|
||||
expect(body.quest.status).to.equal("active");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
meta {
|
||||
name: Accept gather intro
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-120: POST accept gather intro — not_started to active for dev-local-1.
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_gather_intro/accept
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("accept returns 200 with accepted true and active quest row", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.accepted).to.equal(true);
|
||||
expect(body.reasonCode).to.equal(undefined);
|
||||
expect(body.quest).to.be.an("object");
|
||||
expect(body.quest.questId).to.equal("prototype_quest_gather_intro");
|
||||
expect(body.quest.status).to.equal("active");
|
||||
expect(body.quest.currentStepIndex).to.equal(0);
|
||||
expect(body.quest.objectiveCounters).to.eql({});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
meta {
|
||||
name: Accept refine prerequisite deny
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-120: refine intro requires completed gather intro — deny prerequisite_incomplete without mutation.
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_refine_intro/accept
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("denies accept with prerequisite_incomplete", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.accepted).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("prerequisite_incomplete");
|
||||
expect(body.quest).to.equal(undefined);
|
||||
});
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E7.M1 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) per-player GET **landed**; accept + client from **E7M1-09** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
|
||||
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) per-player GET **landed**; **E7M1-09** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) accept POST **landed**; client from **E7M1-11** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
|
||||
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
|
||||
|
||||
## Purpose
|
||||
|
|
@ -84,12 +84,14 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
|
|||
|
||||
**Player quest state store (NEO-116):** **`IPlayerQuestStateStore`** — in-memory + Postgres (`V008__player_quest_progress.sql`) per-player rows keyed by `(playerId, questId)`; implicit `not_started` when missing ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116).
|
||||
|
||||
**Quest state operations (NEO-117):** **`QuestStateOperations`** — accept, step advance, complete with **`QuestStateReasonCodes`**; prerequisite gate on accept; idempotent complete at operations layer ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). HTTP accept deferred to E7M1-09.
|
||||
**Quest state operations (NEO-117):** **`QuestStateOperations`** — accept, step advance, complete with **`QuestStateReasonCodes`**; prerequisite gate on accept; idempotent complete at operations layer ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117).
|
||||
|
||||
**Quest objective wiring (NEO-118):** **`QuestObjectiveWiring`** — gather/craft/encounter success hooks + **`inventory_has_item`** on accept/step advance/inventory mutations; auto advance/complete via **`QuestStateOperations`** ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118).
|
||||
|
||||
**Per-player quest progress GET (NEO-119):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; registry + store merge; GET-side **`inventory_has_item`** refresh ([NEO-119](../../plans/NEO-119-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/`.
|
||||
|
||||
**Quest accept POST (NEO-120):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; wires **`QuestStateOperations.TryAccept`**; structured **`accepted`** / **`reasonCode`** / optional **`quest`** row ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`.
|
||||
|
||||
**Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2.
|
||||
|
||||
## Risks and telemetry
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
|||
|---|---|---|---|---|---|
|
||||
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned |
|
||||
|
||||
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); **E7M1-06 / NEO-117** **`QuestStateOperations`** landed ([NEO-117 plan](../../plans/NEO-117-implementation-plan.md)); **E7M1-07 / NEO-118** **`QuestObjectiveWiring`** landed ([NEO-118 plan](../../plans/NEO-118-implementation-plan.md)); **E7M1-08 / NEO-119** per-player GET landed ([NEO-119 plan](../../plans/NEO-119-implementation-plan.md)); register row stays **Planned** until E7M1-09+ accept HTTP and client capstone.
|
||||
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); **E7M1-06 / NEO-117** **`QuestStateOperations`** landed ([NEO-117 plan](../../plans/NEO-117-implementation-plan.md)); **E7M1-07 / NEO-118** **`QuestObjectiveWiring`** landed ([NEO-118 plan](../../plans/NEO-118-implementation-plan.md)); **E7M1-08 / NEO-119** per-player GET landed ([NEO-119 plan](../../plans/NEO-119-implementation-plan.md)); **E7M1-09 / NEO-120** accept POST landed ([NEO-120 plan](../../plans/NEO-120-implementation-plan.md)); register row stays **Planned** until client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123).
|
||||
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned |
|
||||
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
|
||||
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |
|
||||
|
|
|
|||
|
|
@ -292,8 +292,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Successful accept transitions row **`not_started` → `active`**.
|
||||
- [ ] Deny returns structured **`reasonCode`** without mutation.
|
||||
- [x] Successful accept transitions row **`not_started` → `active`**.
|
||||
- [x] Deny returns structured **`reasonCode`** without mutation.
|
||||
|
||||
**Client counterpart:** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — accept binding + feedback label.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
# NEO-120 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-120 |
|
||||
| **Title** | E7M1-09: POST /game/players/{id}/quests/{questId}/accept |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-120/e7m1-09-post-gameplayersidquestsquestidaccept |
|
||||
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-09** |
|
||||
| **Branch** | `NEO-120-quest-accept-post` |
|
||||
| **Precursor** | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) — `QuestStateOperations.TryAccept` (**landed on `main`**); [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) — GET quest-progress (**landed on `main`**) |
|
||||
| **Pattern** | [NEO-117](NEO-117-implementation-plan.md) — operations + reason codes; [NEO-119](NEO-119-implementation-plan.md) — quest progress row JSON; [NEO-55](NEO-55-implementation-plan.md) / `PlayerInventoryApi` — mutation envelope (`applied` + `reasonCode` + nested snapshot, HTTP **200** on deny) |
|
||||
| **Blocks** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — client quest progress + accept HUD (E7M1-11) |
|
||||
| **Client counterpart** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — accept binding + feedback label polls this POST then GET quest-progress. Bruno-only accept verification is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No clarifications needed.** [E7M1-09](E7M1-prototype-backlog.md#e7m1-09--post-gameplayersidquestsquestidaccept) backlog, Linear AC, landed [NEO-117](NEO-117-implementation-plan.md) operations, and mutation HTTP precedents (`PlayerInventoryApi`, `PlayerCraftApi`, `AbilityCastApi`) settle scope:
|
||||
|
||||
- **Response envelope:** `accepted` + optional `reasonCode` + optional single **`quest`** row (`QuestProgressRowJson` shape from NEO-119) — backlog “updated progress row or deny payload”; inventory-style **200** on structured deny.
|
||||
- **Request body:** optional v1 — omit body, empty body, or `{ "schemaVersion": 1 }` all valid; **400** only when body is present with wrong `schemaVersion`.
|
||||
- **Player gate:** `IPositionStateStore` → **404** for unknown/blank ids (GET quest-progress / inventory precedent) before `TryAccept`.
|
||||
- **Unknown quest:** **200** + `accepted: false` + `reasonCode: unknown_quest` (operations layer; not HTTP 404).
|
||||
- **Deny with snapshot:** `already_active` / `already_completed` include current **`quest`** row from operations snapshot (no mutation on deny).
|
||||
- **Manual QA doc:** skip `docs/manual-qa/NEO-120.md` — server-only; Bruno + API integration tests (NEO-119 precedent).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Expose player-initiated quest accept over HTTP so Godot (and Bruno) can transition a quest from **`not_started` → `active`** with structured deny feedback when prerequisites or state block accept.
|
||||
|
||||
**In scope (from Linear + [E7M1-09](E7M1-prototype-backlog.md#e7m1-09--post-gameplayersidquestsquestidaccept)):**
|
||||
|
||||
- **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi`; optional request body v1; response with **`accepted`**, optional **`reasonCode`**, optional **`quest`** progress row.
|
||||
- Wire **`QuestStateOperations.TryAccept`** (inventory refresh + step completion on accept already in operations).
|
||||
- Shared row projection with GET (`QuestProgressRowJson` from `QuestStepState` / missing row).
|
||||
- Bruno `bruno/neon-sprawl-server/quest-progress/` — happy accept, prerequisite deny, duplicate accept.
|
||||
- Integration tests (AAA): 404 player gate; happy accept; `prerequisite_incomplete`; `already_active`; `unknown_quest`.
|
||||
- `server/README.md` accept route section.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Abandon/reset POST.
|
||||
- Godot HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)).
|
||||
- Telemetry hook comments ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)).
|
||||
- Dev fixture reset API for quest rows.
|
||||
- `docs/manual-qa/NEO-120.md` (server-only per kickoff).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Successful accept transitions row **`not_started` → `active`**.
|
||||
- [x] Deny returns structured **`reasonCode`** without mutation.
|
||||
- [x] Bruno + integration tests pass in CI.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Route:** `POST /game/players/{id}/quests/{questId}/accept` — `QuestAcceptApi.MapResponse`; 404 via `IPositionStateStore`; optional body v1.
|
||||
- **DTOs:** `QuestAcceptRequest`, `QuestAcceptResponse` (`accepted`, `reasonCode`, `quest` row).
|
||||
- **Shared mapper:** `QuestProgressApi.MapQuestProgressRow` overloads for GET + accept POST.
|
||||
- **Tests:** ten AAA cases in `QuestAcceptApiTests` (incl. `already_completed` deny snapshot, empty `{}` body).
|
||||
- **Bruno:** accept gather intro, refine prerequisite deny, duplicate accept.
|
||||
- **Docs:** `server/README.md`; E7.M1 module snapshot + backlog updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Route:** **`POST /game/players/{id}/quests/{questId}/accept`** — inject **`IPositionStateStore`**, **`IQuestDefinitionRegistry`**, **`IPlayerQuestStateStore`**, **`IPlayerInventoryStore`**, **`IItemDefinitionRegistry`**, **`TimeProvider`**.
|
||||
|
||||
2. **Request:** `QuestAcceptRequest` with `schemaVersion` **1**. Handler accepts **`null`**, empty JSON, or `{ "schemaVersion": 1 }`. **400** when body is non-null and `schemaVersion` ≠ 1.
|
||||
|
||||
3. **Player gate:** Trim `id`; return **404** when empty or position missing (mirror **`QuestProgressApi`** GET).
|
||||
|
||||
4. **Accept:** `QuestStateOperations.TryAccept(trimmedId, questId, …)` — `questId` from route (operations normalizes via registry).
|
||||
|
||||
5. **Response mapping** (`QuestAcceptResponse` v1):
|
||||
|
||||
| Outcome | HTTP | `accepted` | `reasonCode` | `quest` row |
|
||||
|---------|------|------------|--------------|-------------|
|
||||
| Success | 200 | `true` | omitted | active row from snapshot |
|
||||
| Deny (no snapshot) | 200 | `false` | operations code | omitted |
|
||||
| Deny (with snapshot) | 200 | `false` | operations code | row from snapshot (`already_active`, `already_completed`) |
|
||||
|
||||
Map `QuestStepState` → `QuestProgressRowJson` via shared helper extracted from **`QuestProgressApi.MapRow`** (e.g. **`MapQuestProgressRow`**) so GET and POST stay aligned.
|
||||
|
||||
6. **Register:** `app.MapQuestAcceptApi()` in **`Program.cs`** immediately after **`MapQuestProgressApi()`**.
|
||||
|
||||
7. **Bruno** (`bruno/neon-sprawl-server/quest-progress/`):
|
||||
|
||||
- **`Accept gather intro.bru`** — POST `dev-local-1` / `prototype_quest_gather_intro` → 200, `accepted: true`, `quest.status === active`, `currentStepIndex === 0`.
|
||||
- **`Accept refine prerequisite deny.bru`** — POST refine intro without gather complete → 200, `accepted: false`, `reasonCode === prerequisite_incomplete`, no active refine row on subsequent GET.
|
||||
- **`Accept duplicate.bru`** — accept gather twice → second 200, `accepted: false`, `reasonCode === already_active`, `quest.status === active`.
|
||||
|
||||
8. **Docs:** Add **`server/README.md`** POST accept section (curl, response shape, reason codes). Update E7.M1 module snapshot / alignment register / E7M1 backlog on story land.
|
||||
|
||||
### Expected v1 success response (prototype)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"accepted": true,
|
||||
"quest": {
|
||||
"questId": "prototype_quest_gather_intro",
|
||||
"status": "active",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Expected v1 deny response (prerequisite)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"accepted": false,
|
||||
"reasonCode": "prerequisite_incomplete"
|
||||
}
|
||||
```
|
||||
|
||||
### Accept flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
R[POST accept] --> B{Body schema ok?}
|
||||
B -->|no| B400[400]
|
||||
B -->|yes| G{Known player?}
|
||||
G -->|no| N404[404]
|
||||
G -->|yes| A[QuestStateOperations.TryAccept]
|
||||
A --> M[Map QuestAcceptResponse]
|
||||
M --> J[JSON 200]
|
||||
```
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs` | `Map*` extension; player gate; `TryAccept`; response mapping. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestAcceptDtos.cs` | Optional request + v1 accept response DTOs. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | AAA HTTP integration: 404, happy accept, prerequisite deny, duplicate accept, unknown quest. |
|
||||
| `bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru` | Bruno happy-path accept. |
|
||||
| `bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru` | Bruno prerequisite deny. |
|
||||
| `bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru` | Bruno duplicate accept deny. |
|
||||
| `docs/plans/NEO-120-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Extract shared `MapQuestProgressRow` (or equivalent) for GET + accept POST row projection. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `MapQuestAcceptApi()` after `MapQuestProgressApi()`. |
|
||||
| `server/README.md` | Document POST accept route, request/response shape, reason codes; replace E7M1-09 deferral stub. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | HTTP quest accept bullet (on story complete). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — NEO-120 HTTP (on story complete). |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — NEO-120 (on story complete). |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` | E7M1-09 acceptance checkboxes (on story complete). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `QuestAcceptApiTests.cs` | **404:** unknown player + whitespace id. **Happy accept:** POST gather intro → `accepted: true`, `quest.status` active, step 0, empty counters; GET confirms active. **Prerequisite deny:** POST refine intro without gather complete → `prerequisite_incomplete`, GET still `not_started` for refine. **Duplicate accept:** second POST → `already_active` with `quest` snapshot. **Completed deny:** accept + complete gather intro → re-accept → `already_completed` with `quest.status` completed + `completedAt`. **Unknown quest:** bogus quest id → `unknown_quest`. **Optional body:** POST with no body or `{}` succeeds for happy accept. |
|
||||
| Existing quest test suite | Regression smoke — operations + GET unchanged when accept not called. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Response field `accepted` vs `applied`** | Use **`accepted`** — matches `AbilityCastResponse` / perk `selected` mutation naming; backlog does not mandate `applied` | **adopted** |
|
||||
| **Shared row mapper location** | Extract from `QuestProgressApi` to internal static helper in same file or small shared type — avoid duplicating status/counter mapping | **adopted** |
|
||||
| **Accept may auto-complete first step** | Operations already run inventory wiring + step completion on accept (NEO-118); tests assert post-accept row as returned, not forced step 0 if wiring advances | **adopted** |
|
||||
| **Bruno collection ordering** | Add accept `.bru` files beside existing quest-progress folder; no new collection root | **adopted** |
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# Code review — NEO-120 (E7M1-09)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-120-quest-accept-post` vs `origin/main` — commits `b22b68a` … `9fe6cb3`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-120 adds **`POST /game/players/{id}/quests/{questId}/accept`** — a thin HTTP wrapper over landed **`QuestStateOperations.TryAccept`**. The handler mirrors **`QuestProgressApi`** (trim id → position-store **404**), accepts optional v1 body with **`schemaVersion` 0 treated as omitted**, and returns a versioned **`QuestAcceptResponse`** (`accepted`, optional `reasonCode`, optional **`quest`** row) with **HTTP 200** on structured denies (inventory/craft precedent). Row projection is shared via extracted **`QuestProgressApi.MapQuestProgressRow(QuestStepState)`** overloads so GET and POST stay aligned. Eight AAA integration tests cover player 404 gates, happy accept (with GET confirmation), omitted body, invalid schema, prerequisite deny, duplicate accept snapshot, and unknown quest. Three Bruno accept spine requests complement the existing quest-progress folder. Plan reconciliation, **`server/README.md`**, and E7.M1 decomposition docs are updated. Server-only slice — Godot accept HUD correctly deferred to NEO-122. Risk is low; no blocking correctness or contract issues found.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-120-implementation-plan.md` | **Matches** — kickoff decisions adopted (`accepted` naming, shared mapper, optional body v1); acceptance checklist checked; reconciliation section accurate. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-09) | **Matches** — acceptance checkboxes checked; client counterpart NEO-122 noted. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — NEO-120 accept POST bullet + README/Bruno links. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M1-09 / NEO-120 landed note appended. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — NEO-120 note appended. |
|
||||
| `server/README.md` | **Matches** — route, optional body rules, response table, curl sample, wiring note. |
|
||||
| Full-stack epic decomposition | **Matches** — server-only story; plan explicitly defers Godot to NEO-122; does not claim prototype slice complete. |
|
||||
|
||||
**Register / tracking:** E7.M1 row correctly remains **Planned** until client capstone (NEO-123); no further register status change required for this merge.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**HTTP test for `already_completed` deny with snapshot** — The plan response table and README document **`already_completed`** returning **`accepted: false`** plus a **`quest`** row. Integration tests cover **`already_active`** and operations-layer **`TryAccept_ShouldDenyAlreadyCompleted`** exists in `QuestStateOperationsTests`, but no HTTP test exercises **`MapResponse`** for a completed row (status **`completed`**, optional **`completedAt`**). A single test completing gather intro then re-accepting would lock the wire contract for NEO-122.~~ **Done.** `PostQuestAccept_ShouldDenyAlreadyCompleted_WhenQuestAlreadyCompleted` in `QuestAcceptApiTests.cs`.
|
||||
|
||||
2. ~~**Empty `{}` body test** — README documents `{}` as valid (default **`schemaVersion` 0**). Tests cover omitted body and explicit **`schemaVersion: 1`**; a one-liner POST with **`StringContent("{}")`** would close the loop on the documented third valid shape.~~ **Done.** `PostQuestAccept_ShouldReturnAcceptedTrue_WhenRequestBodyIsEmptyObject` in `QuestAcceptApiTests.cs`.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: **`MapQuestProgressRow(QuestStepState)`** throws **`InvalidOperationException`** on unknown **`QuestProgressStatus`** (would surface as 500). Inherited defensive guard from NEO-119; acceptable for prototype.
|
||||
|
||||
- ~~Nit: **`PostQuestAccept_ShouldDenyAlreadyActive_WhenQuestAlreadyAccepted`** asserts on the first accept response inside **Arrange** (lines 159–162). Works fine; optional move of **`firstBody`** checks into **Assert** if strict AAA phase purity is desired.~~ **Done.** Arrange seeds active row without assertions; assert uses step index **0**.
|
||||
|
||||
- Nit: Bruno **`Accept duplicate.bru`** pre-request re-accepts gather intro (idempotent when seq 3 ran). Correct for standalone runs; collection order matters only when hitting a persistent dev server without reset.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Primary signal for this story
|
||||
cd server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~QuestAcceptApiTests"
|
||||
|
||||
# Quest module regression
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~NeonSprawl.Server.Tests.Game.Quests"
|
||||
|
||||
# Full server suite (CI)
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
**Bruno:** run the three accept requests in `bruno/neon-sprawl-server/quest-progress/` (seq 3–5) against a local server with fresh `dev-local-1`.
|
||||
|
||||
**Reviewer note:** All 8 `QuestAcceptApiTests` and 93 quest-namespace tests passed locally during review. **Follow-up:** suggestions 1–2 and nit on `already_active` AAA addressed — 10 `QuestAcceptApiTests` pass.
|
||||
|
||||
**Manual:** None required beyond Bruno for this server-only story; Godot accept binding verification belongs to NEO-122.
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
||||
public sealed class QuestAcceptApiTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/missing-player/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/%20/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnAcceptedTrue_WhenGatherIntroNotStarted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(QuestAcceptResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.True(body.Accepted);
|
||||
Assert.Null(body.ReasonCode);
|
||||
Assert.NotNull(body.Quest);
|
||||
Assert.Equal(GatherQuestId, body.Quest!.QuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusActive, body.Quest.Status);
|
||||
Assert.Equal(0, body.Quest.CurrentStepIndex);
|
||||
Assert.Empty(body.Quest.ObjectiveCounters);
|
||||
Assert.Null(body.Quest.CompletedAt);
|
||||
|
||||
var getResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||
var progress = await getResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||
Assert.NotNull(progress);
|
||||
var row = Assert.Single(progress!.Quests, static r => r.QuestId == GatherQuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusActive, row.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnAcceptedTrue_WhenRequestBodyOmitted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
content: null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Accepted);
|
||||
Assert.Equal(GatherQuestId, body.Quest!.QuestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnAcceptedTrue_WhenRequestBodyIsEmptyObject()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
using var content = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Accepted);
|
||||
Assert.Equal(GatherQuestId, body.Quest!.QuestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnBadRequest_WhenSchemaVersionInvalid()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(new { schemaVersion = 99 }),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyPrerequisiteIncomplete_WhenRefineIntroBeforeGatherComplete()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{RefineQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Accepted);
|
||||
Assert.Equal(QuestStateReasonCodes.PrerequisiteIncomplete, body.ReasonCode);
|
||||
Assert.Null(body.Quest);
|
||||
|
||||
var getResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
var progress = await getResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||
Assert.NotNull(progress);
|
||||
var row = Assert.Single(progress!.Quests, static r => r.QuestId == RefineQuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyAlreadyActive_WhenQuestAlreadyAccepted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
_ = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Accepted);
|
||||
Assert.Equal(QuestStateReasonCodes.AlreadyActive, body.ReasonCode);
|
||||
Assert.NotNull(body.Quest);
|
||||
Assert.Equal(QuestProgressApi.StatusActive, body.Quest!.Status);
|
||||
Assert.Equal(0, body.Quest.CurrentStepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyAlreadyCompleted_WhenQuestAlreadyCompleted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var questRegistry = factory.Services.GetRequiredService<IQuestDefinitionRegistry>();
|
||||
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
factory.Services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
factory.Services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
timeProvider).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
timeProvider).Success);
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Accepted);
|
||||
Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, body.ReasonCode);
|
||||
Assert.NotNull(body.Quest);
|
||||
Assert.Equal(QuestProgressApi.StatusCompleted, body.Quest!.Status);
|
||||
Assert.Equal(0, body.Quest.CurrentStepIndex);
|
||||
Assert.NotNull(body.Quest.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/prototype_quest_missing/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Accepted);
|
||||
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
|
||||
Assert.Null(body.Quest);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Maps <c>POST /game/players/{{id}}/quests/{{questId}}/accept</c> (NEO-120).</summary>
|
||||
public static class QuestAcceptApi
|
||||
{
|
||||
public static WebApplication MapQuestAcceptApi(this WebApplication app)
|
||||
{
|
||||
app.MapPost(
|
||||
"/game/players/{id}/quests/{questId}/accept",
|
||||
(string id, string questId, QuestAcceptRequest? body, IPositionStateStore positions,
|
||||
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
|
||||
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is not null &&
|
||||
body.SchemaVersion != 0 &&
|
||||
body.SchemaVersion != QuestAcceptRequest.CurrentSchemaVersion)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
var trimmedId = id.Trim();
|
||||
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
trimmedId,
|
||||
questId,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
timeProvider);
|
||||
|
||||
return Results.Json(MapResponse(result));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static QuestAcceptResponse MapResponse(QuestStateOperationResult result)
|
||||
{
|
||||
QuestProgressRowJson? quest = null;
|
||||
if (result.Snapshot is { } snapshot)
|
||||
{
|
||||
quest = QuestProgressApi.MapQuestProgressRow(snapshot);
|
||||
}
|
||||
|
||||
return new QuestAcceptResponse
|
||||
{
|
||||
Accepted = result.Success,
|
||||
ReasonCode = result.ReasonCode,
|
||||
Quest = quest,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Optional POST body for <c>POST /game/players/{{id}}/quests/{{questId}}/accept</c> (NEO-120).</summary>
|
||||
public sealed class QuestAcceptRequest
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>POST response for quest accept (NEO-120).</summary>
|
||||
public sealed class QuestAcceptResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("accepted")]
|
||||
public bool Accepted { get; init; }
|
||||
|
||||
[JsonPropertyName("reasonCode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReasonCode { get; init; }
|
||||
|
||||
[JsonPropertyName("quest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public QuestProgressRowJson? Quest { get; init; }
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ public static class QuestProgressApi
|
|||
var rows = new List<QuestProgressRowJson>(defs.Count);
|
||||
foreach (var def in defs)
|
||||
{
|
||||
rows.Add(MapRow(playerId, def.Id, progressStore));
|
||||
rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore));
|
||||
}
|
||||
|
||||
return new QuestProgressListResponse
|
||||
|
|
@ -59,35 +59,34 @@ public static class QuestProgressApi
|
|||
};
|
||||
}
|
||||
|
||||
private static QuestProgressRowJson MapRow(
|
||||
internal static QuestProgressRowJson MapQuestProgressRow(
|
||||
string playerId,
|
||||
string questId,
|
||||
IPlayerQuestStateStore progressStore)
|
||||
{
|
||||
if (!progressStore.TryGetProgress(playerId, questId, out var snapshot))
|
||||
{
|
||||
return new QuestProgressRowJson
|
||||
{
|
||||
QuestId = questId,
|
||||
Status = StatusNotStarted,
|
||||
CurrentStepIndex = 0,
|
||||
ObjectiveCounters = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||
};
|
||||
return NotStartedRow(questId);
|
||||
}
|
||||
|
||||
return MapQuestProgressRow(snapshot);
|
||||
}
|
||||
|
||||
internal static QuestProgressRowJson MapQuestProgressRow(QuestStepState snapshot)
|
||||
{
|
||||
var counters = new Dictionary<string, int>(snapshot.ObjectiveCounters, StringComparer.Ordinal);
|
||||
return snapshot.Status switch
|
||||
{
|
||||
QuestProgressStatus.Active => new QuestProgressRowJson
|
||||
{
|
||||
QuestId = questId,
|
||||
QuestId = snapshot.QuestId,
|
||||
Status = StatusActive,
|
||||
CurrentStepIndex = snapshot.CurrentStepIndex,
|
||||
ObjectiveCounters = counters,
|
||||
},
|
||||
QuestProgressStatus.Completed => new QuestProgressRowJson
|
||||
{
|
||||
QuestId = questId,
|
||||
QuestId = snapshot.QuestId,
|
||||
Status = StatusCompleted,
|
||||
CurrentStepIndex = snapshot.CurrentStepIndex,
|
||||
ObjectiveCounters = counters,
|
||||
|
|
@ -96,4 +95,13 @@ public static class QuestProgressApi
|
|||
_ => throw new InvalidOperationException($"Unknown quest progress status '{snapshot.Status}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private static QuestProgressRowJson NotStartedRow(string questId) =>
|
||||
new()
|
||||
{
|
||||
QuestId = questId,
|
||||
Status = StatusNotStarted,
|
||||
CurrentStepIndex = 0,
|
||||
ObjectiveCounters = new Dictionary<string, int>(StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ app.MapSkillProgressionSnapshotApi();
|
|||
app.MapGigProgressionSnapshotApi();
|
||||
app.MapEncounterProgressApi();
|
||||
app.MapQuestProgressApi();
|
||||
app.MapQuestAcceptApi();
|
||||
app.MapPerkStateApi();
|
||||
if (app.Environment.IsDevelopment() ||
|
||||
app.Environment.IsEnvironment("Testing") ||
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ curl -sS -i "http://localhost:5253/game/world/quest-definitions"
|
|||
|
||||
## Quest progress store (NEO-116)
|
||||
|
||||
Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Game code should call **`QuestStateOperations`** (NEO-117) for accept/advance/complete — not the store directly — so quest ids and prerequisites are validated. HTTP accept lands in E7M1-09 (NEO-120).
|
||||
Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Game code should call **`QuestStateOperations`** (NEO-117) for accept/advance/complete — not the store directly — so quest ids and prerequisites are validated. HTTP accept: **`POST …/quests/{questId}/accept`** (NEO-120).
|
||||
|
||||
**Store interface methods:**
|
||||
|
||||
|
|
@ -224,6 +224,32 @@ Sample default response (no quests accepted):
|
|||
{"schemaVersion":1,"playerId":"dev-local-1","quests":[{"questId":"prototype_quest_combat_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_gather_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_operator_chain","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_refine_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}}]}
|
||||
```
|
||||
|
||||
### Quest accept POST (NEO-120)
|
||||
|
||||
**`POST /game/players/{id}/quests/{questId}/accept`** transitions **`not_started` → `active`** via **`QuestStateOperations.TryAccept`**. Unknown or blank player ids return **404** (position gate). Request body is optional v1 — omit body, send `{}`, or `{ "schemaVersion": 1 }`; **400** when body is present with `schemaVersion` other than **1** (unset `0` is treated as omitted).
|
||||
|
||||
Response (`schemaVersion` **1**): **`accepted`** (`true`/`false`), optional **`reasonCode`** on deny, optional **`quest`** row (same shape as GET list entries). Structured denies return **HTTP 200** with **`accepted: false`** (inventory/craft precedent). Accept-time **`inventory_has_item`** wiring runs inside **`TryAccept`** (NEO-118).
|
||||
|
||||
| Outcome | `accepted` | `reasonCode` | `quest` row |
|
||||
|---------|------------|--------------|-------------|
|
||||
| Success | `true` | omitted | active snapshot |
|
||||
| Deny (no row) | `false` | e.g. `unknown_quest`, `prerequisite_incomplete` | omitted |
|
||||
| Deny (existing row) | `false` | e.g. `already_active`, `already_completed` | current snapshot |
|
||||
|
||||
Plan: [NEO-120 implementation plan](../../docs/plans/NEO-120-implementation-plan.md); Bruno `bruno/neon-sprawl-server/quest-progress/` (accept spine).
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/quests/prototype_quest_gather_intro/accept" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1}'
|
||||
```
|
||||
|
||||
Sample success:
|
||||
|
||||
```json
|
||||
{"schemaVersion":1,"accepted":true,"quest":{"questId":"prototype_quest_gather_intro","status":"active","currentStepIndex":0,"objectiveCounters":{}}}
|
||||
```
|
||||
|
||||
## Encounter definitions (NEO-103)
|
||||
|
||||
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue