diff --git a/docs/plans/NEO-151-implementation-plan.md b/docs/plans/NEO-151-implementation-plan.md new file mode 100644 index 0000000..0ad11ca --- /dev/null +++ b/docs/plans/NEO-151-implementation-plan.md @@ -0,0 +1,196 @@ +# 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 + +- [ ] 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 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.