neon-sprawl/docs/plans/NEO-38-implementation-plan.md

108 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# NEO-38 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-38 |
| **Title** | Apply skill XP grant + allowlist + level-up |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-38/apply-skill-xp-grant-allowlist-level-up |
| **Module** | [E2.M2 — XpAwardAndLevelEngine](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) |
| **Blocked by** | [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (read snapshot) — implemented on `main`; this branch builds on that contract. |
## Kickoff clarifications
| Topic | Question / note | Resolution |
|--------|-----------------|------------|
| **Persistence** | NEO-38 AC: follow NEO-29 pattern vs in-memory-only + follow-up | **User:** mirror **Hotbar / NEO-29** — abstract store, **`InMemory*`** fallback, **`Postgres*`** when `ConnectionStrings__NeonSprawl` is set, with schema bootstrap analogous to **`PostgresHotbarLoadoutBootstrap`**. |
| **Successful POST body** | Surface level-up in response vs minimal + GET only | User asked for recommendation → **Agent:** **HTTP 200** with **versioned JSON** that includes **full progression snapshot** (aligned with **`GET …/skill-progression`**) plus an explicit **`levelUps`** (or equivalent) array for thresholds crossed on this grant. **User:** confirmed snapshot + level-ups. |
## Goal, scope, and out-of-scope
**Goal:** Server-authoritative **apply XP grant** path: validate **`skillId`** (`ISkillDefinitionRegistry.TryGetDefinition`) and **`sourceKind`** against **`SkillDefRow.AllowedXpSourceKinds`**; persist per-player XP; recompute level from an **inline placeholder curve** (acceptable until [NEO-39](https://linear.app/neon-sprawl/issue/NEO-39)); return **structured denies** (stable reason codes, hotbar-style **200 + `granted: false`** where that pattern already fits the repo) and **level-up** data on success.
**In scope (from Linear):**
- Reject unknown skill id and disallowed `sourceKind` with **stable reason codes**.
- Valid grant updates persistence; **level** increases when thresholds crossed (**`LevelUpEvent`** semantics surfaced in JSON for this story).
- Versioned **POST** + automated tests; **Bruno** requests; **`server/README.md`**.
- Persistence: **in-memory + Postgres when configured**, per kickoff decision.
**Out of scope (from Linear):**
- Data-driven level curve file + CI (**NEO-39**).
- Telemetry catalog (**NEO-40**).
- Slice 3 activity wiring (**NEO-41****43**).
## Acceptance criteria checklist
- [x] Unknown `skillId` rejected; `sourceKind` not in allowlist rejected (**stable reason codes**).
- [x] Valid grant updates stored progression; level increases when thresholds crossed (**level-up** payload on success).
- [x] Versioned **POST** + tests; Bruno; README.
- [x] Persistence: **NEO-29-style** dual backend (in-memory + Postgres when configured).
## Technical approach
1. **Route:** **`POST /game/players/{id}/skill-progression`** (same resource as NEO-37 **GET**, mirroring **`GET` + `POST` …/hotbar-loadout**). **Reject** missing/invalid **`schemaVersion`** with **400** (same idea as hotbar update).
2. **Player gate:** **`404`** when **`!IPositionStateStore.TryGetPosition`**, unchanged from NEO-37.
3. **Request body (versioned):** e.g. **`schemaVersion`**, **`skillId`**, **`amount`** (positive int), **`sourceKind`** (string matching catalog values: `activity`, `mission_reward`, `trainer`, `book_or_item`). Single grant per request for a small, testable surface; batch grants can be a later issue if needed.
4. **Validation:** Use **`ISkillDefinitionRegistry`**. If unknown skill → deny with **`unknown_skill`** (exact string TBD but stable and documented). If known skill but **`sourceKind`** not in **`AllowedXpSourceKinds`** (case/whitespace normalization consistent with catalog) → **`source_kind_not_allowed`**. Non-positive **`amount`** → **`invalid_amount`** (or reuse a generic **`bad_grant`** only if we want fewer codes — prefer specific codes per AC).
5. **Persistence:** Introduce **`IPlayerSkillProgressionStore`** (name may match existing naming conventions) with operations sufficient to **read XP per skill** and **apply a delta** atomically per request, returning new XP and whether level changed. **In-memory** implementation keyed by normalized player id; **Postgres** implementation with **`EnsureSchema`**, **`player_id` + `skill_id`** uniqueness, XP column (level **derived** from XP + curve helper to avoid drift). **No row** for a skill ⇒ treat as **XP 0** / **level 1** at read time (GET and POST deny paths that return current snapshot).
6. **GET snapshot (NEO-37 evolution):** Replace the hard-coded **0 / 1** builder in **`SkillProgressionSnapshotApi.BuildSnapshot`** with **registry + store merge** so persisted XP appears on **`GET /game/players/{id}/skill-progression`**. Preserve **`schemaVersion` 1** if the JSON shape is unchanged (only values differ). Keep **ordinal sort** for stable serialization; **unordered-by-contract** rule unchanged.
7. **Level curve (placeholder):** Inline C# thresholds or formula (documented in code comments, **subsumed by NEO-39**). Example pattern: simple per-level XP thresholds up to a small cap so tests can cross **at least one** level-up with realistic amounts.
8. **Success response:** **HTTP 200**, versioned envelope: e.g. **`granted: true`**, **`progression`** (same shape as **`SkillProgressionSnapshotResponse`** or a shared type), **`levelUps`**: array of **`{ skillId, previousLevel, newLevel }`** (field names camelCase JSON). Empty **`levelUps`** when no threshold crossed.
9. **Deny response:** **HTTP 200** with **`granted: false`**, **`reasonCode`**, and current **`progression`** snapshot (hotbar **`Updated: false`** pattern) so clients always receive authoritative state for UI sync — unless a case clearly fits **400** (malformed body / wrong schema version).
10. **Wire-up:** **`AddSkillProgressionStore`** (or similar) in **`Program.cs`** next to **`AddHotbarLoadoutStore`**. **`InMemoryWebApplicationFactory`** forces the in-memory implementation for isolated tests (same replacement pattern as hotbar/position).
11. **Bruno / docs:** Add **`Post … skill progression grant.bru`** (exact name follows collection style) under **`bruno/neon-sprawl-server/skill-progression/`**; extend **`server/README.md`**. Add **`docs/manual-qa/NEO-38.md`** during implementation per project convention.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs` | Abstraction: read XP map / apply grant for one player+skill. |
| `server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs` | In-memory persistence; normalized player keys. |
| `server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs` | Postgres implementation when connection string present. |
| `server/NeonSprawl.Server/Game/Skills/PostgresSkillProgressionBootstrap.cs` | Schema ensure (mirror hotbar bootstrap style). |
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionServiceCollectionExtensions.cs` | Chooses Postgres vs in-memory from configuration. |
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantDtos.cs` | Versioned POST request + success/deny response DTOs (`levelUps`, `reasonCode`, etc.). |
| `server/NeonSprawl.Server/Game/Skills/SkillLevelCurvePlaceholder.cs` | Inline threshold/floor helpers until NEO-39 (pure functions, unit-testable). |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantApiTests.cs` | In-memory host: unknown skill, bad source kind, success, level-up, GET reflects POST, **404** player gate, bad schema → **400**. |
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionGrantPersistenceIntegrationTests.cs` | Postgres harness: grant survives new host/process (mirror **`HotbarLoadoutPersistenceIntegrationTests`**). |
| `bruno/neon-sprawl-server/skill-progression/Post skill progression grant.bru` | Manual smoke for happy + one deny path. |
| `docs/manual-qa/NEO-38.md` | Curl/Bruno checklist for grant + GET verification. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs` | Register **POST**; inject store; change **GET** builder to merge registry + stored XP via placeholder level curve. |
| `server/NeonSprawl.Server/Program.cs` | Register **`AddSkillProgressionStore`** and ensure catalog/store order remains valid. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Swap/replace **`IPlayerSkillProgressionStore`** with in-memory implementation for tests. |
| `server/README.md` | Document **POST** grant, reason codes, persistence note, Bruno path, links to plan + manual QA. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `SkillProgressionGrantApiTests.cs` | **AAA** integration tests: denies (**unknown_skill**, **source_kind_not_allowed**); success increases XP; **levelUps** when threshold crossed; **GET** matches after grant; **404** without position; malformed / wrong **`schemaVersion`** → **400**. |
| `SkillProgressionGrantPersistenceIntegrationTests.cs` | Postgres: write grant, new factory/client, **GET** (or POST read-back) shows persisted XP. |
| `SkillProgressionSnapshotApiTests.cs` (existing) | Update expectations if **GET** implementation changes from hard-coded zeros to store-backed merge (same defaults when store empty). |
Bruno + **`docs/manual-qa/NEO-38.md`** supplement automated coverage.
## Open questions / risks
- **Reason code naming:** Final strings should be listed once in README and reused in tests/Bruno to avoid drift (prefer **`snake_case`** consistent with **`HotbarLoadoutApi`** constants).
- **Placeholder curve vs. content:** Keep thresholds boring and documented; **NEO-39** replaces the helper without changing persistence schema if XP remains the stored value.
- **Concurrency:** Postgres store should use a transaction per grant; in-memory uses concurrent structures or per-player locks if needed for tests under parallel load (prototype: acceptable to document single-threaded assumption if minimal).