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

197 lines
14 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-151 — E7M4-08: Contract issue POST + per-player contract GET
**Linear:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151)
**Branch:** `NEO-151-e7m4-08-contract-issue-post-per-player-contract-get`
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-08**
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
**Pattern:** [NEO-120-implementation-plan.md](NEO-120-implementation-plan.md) (`QuestAcceptApi` issue envelope); [NEO-129-implementation-plan.md](NEO-129-implementation-plan.md) (`completionRewardSummary` on GET rows); [NEO-147-implementation-plan.md](NEO-147-implementation-plan.md) (`ContractGeneratorOperations.TryIssue`); [NEO-149-implementation-plan.md](NEO-149-implementation-plan.md) (encounter clear → completion + delivery)
**Precursors:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) **`Done`** — `ContractCompletionOperations` + encounter wiring; [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) **`Done`** — issue-time economy validation (**on `main`**)
**Blocks:** [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152) (telemetry hook sites), [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) (Godot contract HUD)
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract panel after this story lands
## Goal
Client-readable contract issuance and state without local math — **`POST …/contracts/issue`** returns an **`active`** instance projection; **`GET …/contracts`** lists the player's active contract (if any) plus recent completed rows with encounter objective id and delivery summary.
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|-------|----------|---------------------|--------|
| GET completed history depth | How many completed rows alongside active? | **Active (01) + up to 10 recent completed**, ordered by **`issuedAt` desc** | **Adopted** — user chose cap 10 |
| POST body shape | Full `contract-seed` vs path-only player id? | **Full seed fields + `schemaVersion` 1**; **`playerId` in body must match path `{id}`** (400 on mismatch); **`templateId` required** per schema + NEO-147 | **Adopted** — schema + NEO-147 precedent |
| Issue response envelope | Raw snapshot vs structured response? | **`{ issued, reasonCode, contract }`** mirroring quest accept **`{ accepted, reasonCode, quest }`** | **Adopted** — NEO-120 pattern |
| Grant summary DTOs | New contract types vs reuse quest summary? | **Reuse `QuestCompletionRewardSummaryJson`** and grant line types — same `IRewardDeliveryStore` snapshot shape | **Adopted** — NEO-129/NEO-140 |
| Store read for GET | Extend `IContractInstanceStore` vs ad-hoc SQL in API? | **Add `ListForPlayer(playerId, maxCompletedRows)`** on interface + both store impls | **Adopted** — store boundary precedent (NEO-146) |
| Issue deny HTTP status | 400 vs 200 with `issued: false`? | **200 OK** with **`issued: false`** + **`reasonCode`** (and optional **`contract`** snapshot on `active_contract_exists`) | **Adopted** — quest accept precedent |
| Bruno scope | Minimal vs full issue → clear → GET loop? | **`bruno/neon-sprawl-server/contracts/`** — issue, GET default, GET after encounter clear (reuse ability-cast / encounter clear spine) | **Adopted** — backlog in-scope |
| Telemetry / Godot | In scope? | **Out of scope** — NEO-152 comment hooks; NEO-153 client HUD | **Adopted** — backlog |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`POST /game/players/{id}/contracts/issue`** — `ContractIssueApi` + request/response DTOs; delegates to **`ContractGeneratorOperations.TryIssue`** with full DI registries (encounter, item, skill, faction per NEO-150 handoff).
- **`GET /game/players/{id}/contracts`** — `ContractListApi` + list DTOs; active row + up to **10** recent completed instances with template summary, **`encounterTemplateId`**, **`completionRewardSummary`** when delivered.
- **`IContractInstanceStore.ListForPlayer`** — read path for completed history (in-memory + Postgres).
- Integration tests (`ContractIssueApiTests` / `ContractListApiTests` or combined `ContractApiTests`).
- Bruno **`bruno/neon-sprawl-server/contracts/`** — issue → encounter clear → GET loop; update contract-stores reset bru cross-link.
- `server/README.md` contract HTTP sections; E7.M4 module anchor.
**Out of scope (from Linear + backlog):**
- Godot parse/HUD (**NEO-153** / E7M4-10).
- Telemetry comment hooks (**NEO-152** / E7M4-09).
- Optional **`templateId`** auto-select over HTTP (ops layer supports null; HTTP requires explicit id per schema).
- Live E4.M1 zone read for **`zoneDifficultyBand`** (POST override only until E4.M1).
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — server HTTP must land in this story first.
## Acceptance criteria checklist
- [ ] POST issue returns **`active`** instance JSON.
- [ ] GET lists active contract with encounter objective id.
- [ ] GET completed row includes delivery summary after encounter clear.
## Technical approach
### 1. Store read — `IContractInstanceStore.ListForPlayer`
```csharp
IReadOnlyList<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows);
```
**Semantics:**
- Normalize **`playerId`**; return empty list when player not writable / unknown to store.
- If an **active** row exists, include it first (at most one).
- Append up to **`maxCompletedRows`** **completed** instances for that player, **`issuedAt` descending** (tie-break **`contractInstanceId`** ordinal).
- Prototype constant **`ContractListApi.MaxCompletedRows = 10`** (kickoff decision).
**Postgres:** `SELECT … WHERE player_id = @pid AND status = 'completed' ORDER BY completed_at DESC NULLS LAST, contract_instance_id LIMIT @n` plus existing active query.
**In-memory:** scan **`byInstanceId`** for player-owned completed rows, sort, take N, prepend active.
Unit tests in **`InMemoryContractInstanceStoreTests`**: empty player, active only, completed only, active + multiple completed ordering, cap respected.
### 2. DTOs (`Game/Contracts/ContractApiDtos.cs`)
| Type | Role |
|------|------|
| **`ContractIssueRequest`** | `schemaVersion` **1**, `playerId`, `templateId`, `seedBucket`, optional `zoneDifficultyBand` — mirrors [`contract-seed.schema.json`](../../content/schemas/contract-seed.schema.json) + version gate |
| **`ContractIssueResponse`** | `schemaVersion`, `issued`, optional `reasonCode`, optional **`ContractInstanceRowJson`** |
| **`ContractListResponse`** | `schemaVersion`, `playerId`, **`contracts`** array |
| **`ContractInstanceRowJson`** | `contractInstanceId`, `templateId`, `templateDisplayName`, `status` (`active` \| `completed`), `encounterTemplateId`, `seedBucket`, `issuedAt`, optional `completedAt`, optional **`completionRewardSummary`** (`QuestCompletionRewardSummaryJson`) |
**Row mapping (`ContractListApi.MapInstanceRow`):**
1. Load template via **`IContractTemplateRegistry.TryGetDefinition`** for **`templateDisplayName`** + **`encounterTemplateId`** fallback.
2. For **`completed`** status, **`IRewardDeliveryStore.TryGet(playerId, RewardDeliverySourceKinds.ContractCompletion, contractInstanceId, …)`** → map grants via same helpers as **`QuestProgressApi.MapCompletionRewardSummary`** (extract shared mapper or duplicate minimally — prefer internal reuse on **`QuestProgressApi`** mappers if accessible, else parallel private helpers in contract API).
### 3. `ContractIssueApi`
**Route:** `POST /game/players/{id}/contracts/issue`
**Handler flow:**
1. **400** when body null, **`schemaVersion`** mismatch, empty **`templateId`** / **`seedBucket`**, or body **`playerId`** ≠ trimmed path **`{id}`** (case-sensitive ordinal after trim — match inventory/quest normalization).
2. **404** when path player unknown (**`IPositionStateStore`** gate — same as quest progress).
3. Call **`ContractGeneratorOperations.TryIssue`** with path player id, body fields, and injected registries/stores (**mirror `ContractGeneratorOperationsIntegrationTests`** DI wiring).
4. **200** **`ContractIssueResponse`** via **`MapIssueResponse`** — on success map **`ContractInstanceRowJson`** from result snapshot + template registry; on deny include **`reasonCode`** and optional snapshot row (e.g. **`active_contract_exists`**).
Register in **`Program.cs`** alongside quest routes: **`app.MapContractIssueApi()`**.
### 4. `ContractListApi`
**Route:** `GET /game/players/{id}/contracts`
**Handler flow:**
1. **404** unknown player (position gate).
2. **`instanceStore.ListForPlayer(trimmedId, ContractListApi.MaxCompletedRows)`**.
3. Map each snapshot to **`ContractInstanceRowJson`**.
4. **200** **`ContractListResponse`** — **`contracts`** may be empty array.
### 5. Integration tests (`ContractApiTests`)
| Test | Covers |
|------|--------|
| POST issue 404 unknown player | Position gate |
| POST issue 400 schema / playerId mismatch | Request validation |
| POST issue 200 happy path | **`issued: true`**, **`active`**, **`encounterTemplateId: prototype_combat_pocket`**, deterministic **`contractInstanceId`** |
| POST issue 200 deny second active | **`issued: false`**, **`active_contract_exists`**, optional active row echo |
| GET 404 unknown player | Position gate |
| GET empty list | Known player, no instances |
| GET active after issue | Active row with encounter id, no summary |
| GET completed after encounter clear | Issue → **`ContractCompletionOperations.TryCompleteOnEncounterClear`** (or ability-cast integration path) → GET asserts **`completed`**, **`completionRewardSummary`** with prototype bundle lines |
| GET idempotent replay | Second GET same summary (mirror NEO-129) |
Optional **`RequirePostgresFact`**: issue + list round-trip on Postgres store (mirror NEO-146 persistence tests).
### 6. Bruno (`bruno/neon-sprawl-server/contracts/`)
| File | Purpose |
|------|---------|
| `Issue prototype contract.bru` | POST issue with frozen template + seed bucket |
| `Get contracts default.bru` | GET empty or active-only baseline |
| `Get contracts after encounter clear.bru` | Full spine: issue → clear **`prototype_combat_pocket`** → GET asserts **`completionRewardSummary`** |
Update **`contract-stores/Reset contract instance via quest fixture.bru`** docs to reference real instance id from issue bru.
### 7. Docs
- **`server/README.md`** — contract issue GET/POST routes, JSON samples, reason codes (link generator/completion sections).
- **`E7_M4_ContractMissionGenerator.md`** — E7M4-08 HTTP landed note.
- **`documentation_and_implementation_alignment.md`** — E7.M4 row after implementation.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-151-implementation-plan.md` | This plan |
| `server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs` | Issue/list request/response + row JSON types |
| `server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs` | `POST …/contracts/issue` handler + mapper |
| `server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs` | `GET …/contracts` handler + row mapper |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs` | AAA HTTP integration tests |
| `bruno/neon-sprawl-server/contracts/Issue prototype contract.bru` | Bruno issue spine |
| `bruno/neon-sprawl-server/contracts/Get contracts default.bru` | Bruno GET baseline |
| `bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru` | Bruno issue → clear → GET capstone |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs` | Add **`ListForPlayer`** for completed history reads |
| `server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs` | Implement **`ListForPlayer`** |
| `server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs` | Implement **`ListForPlayer`** SQL |
| `server/NeonSprawl.Server/Program.cs` | Register **`MapContractIssueApi`** + **`MapContractListApi`** |
| `server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs` | Unit tests for **`ListForPlayer`** ordering + cap |
| `server/README.md` | Document contract issue/list HTTP routes + samples |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-151 HTTP anchor |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M4 alignment note (post-implementation) |
| `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru` | Cross-link contracts bru folder |
## Tests
| File | What it covers |
|------|----------------|
| `InMemoryContractInstanceStoreTests` | **`ListForPlayer`**: empty, active-only, completed cap, ordering |
| `ContractApiTests` | POST issue happy/deny paths; GET empty/active/completed + **`completionRewardSummary`**; 404/400 gates |
| `ContractGeneratorOperationsIntegrationTests` (existing) | Unchanged — orchestrator DI baseline |
| CI | `dotnet test` green; Bruno manual against dev server |
Manual Godot QA: **none** (server HTTP; capstone **NEO-154**). Bruno: **`contracts/`** folder per plan.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **`TryIssue` registry wiring in HTTP handler** | Pass same four registries as **`ContractGeneratorOperationsIntegrationTests`** — verified at kickoff (NEO-150 handoff) | `adopted` |
| Duplicate grant-summary mapper vs quest API | Extract **`internal`** shared mapper on **`QuestProgressApi`** if clean; else parallel private helpers — keep grant order parity | `pending` |
| GET payload size with 10 completed rows | Accept for prototype; client (**NEO-153**) reads active row first | `adopted` |
| **`contract-seed.schema.json` lacks `schemaVersion`** | HTTP DTO adds **`schemaVersion`** alongside seed fields (quest/inventory precedent); schema file unchanged unless CI needs `$ref` later | `adopted` |
| Postgres **`ListForPlayer`** index | Existing **`player_id`** + status filter sufficient for prototype volume; no new migration | `adopted` |
## Client counterpart
[NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — **`contract_client.gd`**, issue binding, HUD labels; blocked until this story merges. Surfaces deny codes including **`economy_cap_exceeded`** and **`invalid_reward_bundle`** from NEO-150.