14 KiB
NEO-151 — E7M4-08: Contract issue POST + per-player contract GET
Linear: NEO-151
Branch: NEO-151-e7m4-08-contract-issue-post-per-player-contract-get
Backlog: E7M4-pre-production-backlog.md — E7M4-08
Module: E7_M4_ContractMissionGenerator.md
Pattern: NEO-120-implementation-plan.md (QuestAcceptApi issue envelope); NEO-129-implementation-plan.md (completionRewardSummary on GET rows); NEO-147-implementation-plan.md (ContractGeneratorOperations.TryIssue); NEO-149-implementation-plan.md (encounter clear → completion + delivery)
Precursors: NEO-149 Done — ContractCompletionOperations + encounter wiring; NEO-150 Done — issue-time economy validation (on main)
Blocks: NEO-152 (telemetry hook sites), NEO-153 (Godot contract HUD)
Client counterpart: 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 toContractGeneratorOperations.TryIssuewith 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,completionRewardSummarywhen delivered.IContractInstanceStore.ListForPlayer— read path for completed history (in-memory + Postgres).- Integration tests (
ContractIssueApiTests/ContractListApiTestsor combinedContractApiTests). - Bruno
bruno/neon-sprawl-server/contracts/— issue → encounter clear → GET loop; update contract-stores reset bru cross-link. server/README.mdcontract 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
templateIdauto-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 — server HTTP must land in this story first.
Acceptance criteria checklist
- POST issue returns
activeinstance 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
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
maxCompletedRowscompleted instances for that player,issuedAtdescending (tie-breakcontractInstanceIdordinal). - 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 + 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):
- Load template via
IContractTemplateRegistry.TryGetDefinitionfortemplateDisplayName+encounterTemplateIdfallback. - For
completedstatus,IRewardDeliveryStore.TryGet(playerId, RewardDeliverySourceKinds.ContractCompletion, contractInstanceId, …)→ map grants via same helpers asQuestProgressApi.MapCompletionRewardSummary(extract shared mapper or duplicate minimally — prefer internal reuse onQuestProgressApimappers if accessible, else parallel private helpers in contract API).
3. ContractIssueApi
Route: POST /game/players/{id}/contracts/issue
Handler flow:
- 400 when body null,
schemaVersionmismatch, emptytemplateId/seedBucket, or bodyplayerId≠ trimmed path{id}(case-sensitive ordinal after trim — match inventory/quest normalization). - 404 when path player unknown (
IPositionStateStoregate — same as quest progress). - Call
ContractGeneratorOperations.TryIssuewith path player id, body fields, and injected registries/stores (mirrorContractGeneratorOperationsIntegrationTestsDI wiring). - 200
ContractIssueResponseviaMapIssueResponse— on success mapContractInstanceRowJsonfrom result snapshot + template registry; on deny includereasonCodeand 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:
- 404 unknown player (position gate).
instanceStore.ListForPlayer(trimmedId, ContractListApi.MaxCompletedRows).- Map each snapshot to
ContractInstanceRowJson. - 200
ContractListResponse—contractsmay 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 — 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.