206 lines
15 KiB
Markdown
206 lines
15 KiB
Markdown
# 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 (0–1) + 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
|
||
|
||
- [x] POST issue returns **`active`** instance JSON.
|
||
- [x] GET lists active contract with encounter objective id.
|
||
- [x] GET completed row includes delivery summary after encounter clear.
|
||
|
||
## Implementation reconciliation (shipped)
|
||
|
||
- **`IContractInstanceStore.ListForPlayer`** — active first + up to 10 completed rows (`issuedAt` desc); in-memory + Postgres impls.
|
||
- **`ContractIssueApi`** — `POST …/contracts/issue` delegates to **`ContractGeneratorOperations.TryIssue`** with full registry DI.
|
||
- **`ContractListApi`** — `GET …/contracts` with **`completionRewardSummary`** via shared **`QuestProgressApi`** delivery mapper.
|
||
- **Tests:** 5 store list unit tests + 10 HTTP integration tests; **927** tests green.
|
||
- **Bruno:** `bruno/neon-sprawl-server/contracts/` — default GET, issue, encounter-clear capstone.
|
||
- **Docs:** `server/README.md`, `E7_M4_ContractMissionGenerator.md`, alignment register.
|
||
|
||
## 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 | **`QuestProgressApi.MapCompletionRewardSummary`** extended with `sourceKind` + `sourceId`; contract API reuses it | `adopted` |
|
||
| 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.
|