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

15 KiB
Raw Blame History

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.mdE7M4-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 DoneContractCompletionOperations + 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 (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/issueContractIssueApi + request/response DTOs; delegates to ContractGeneratorOperations.TryIssue with full DI registries (encounter, item, skill, faction per NEO-150 handoff).
  • GET /game/players/{id}/contractsContractListApi + 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 — 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.

Implementation reconciliation (shipped)

  • IContractInstanceStore.ListForPlayer — active first + up to 10 completed rows (issuedAt desc); in-memory + Postgres impls.
  • ContractIssueApiPOST …/contracts/issue delegates to ContractGeneratorOperations.TryIssue with full registry DI.
  • ContractListApiGET …/contracts with completionRewardSummary via shared QuestProgressApi delivery mapper.
  • Tests: 5 store list unit tests + 14 HTTP integration tests (incl. empty seed-field [Theory]) + Postgres ListForPlayer persistence test; 933 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

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 issued_at DESC, contract_instance_id ASC LIMIT @n plus active query on the same connection.

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):

  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. 404 when path {id} is whitespace-only or player unknown (IPositionStateStore gate — same as quest progress / accept).
  2. 400 when body null, schemaVersion mismatch, empty templateId / seedBucket / body playerId, or body playerId ≠ trimmed path {id} after normalize.
  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 ContractListResponsecontracts 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-153contract_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.