From 9994aee3cf54894592bbabcbcd1f3484270eb0fd Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 10:13:39 -0400 Subject: [PATCH 1/7] NEO-151: Add kickoff implementation plan for contract HTTP API Document POST issue and GET list endpoints, store ListForPlayer read path, and kickoff decision to return active plus up to 10 recent completed rows. --- docs/plans/NEO-151-implementation-plan.md | 196 ++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/plans/NEO-151-implementation-plan.md 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. From c0252ee8be39a07ce4d24d7bb4bf432c3e1cf14f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 10:21:10 -0400 Subject: [PATCH 2/7] NEO-151: Add contract issue POST and per-player contract GET HTTP API Wire ContractIssueApi and ContractListApi with ListForPlayer store reads, integration tests, Bruno issue/clear/GET spine, and docs reconciliation. --- ...et contract instance via quest fixture.bru | 2 +- .../Get contracts after encounter clear.bru | 136 +++++++ .../contracts/Get contracts default.bru | 35 ++ .../contracts/Issue prototype contract.bru | 56 +++ bruno/neon-sprawl-server/contracts/folder.bru | 8 + .../reset-contract-instances-helper.js | 30 ++ .../modules/E7_M4_ContractMissionGenerator.md | 2 + ...umentation_and_implementation_alignment.md | 2 +- docs/plans/NEO-151-implementation-plan.md | 17 +- .../Game/Contracts/ContractApiTests.cs | 361 ++++++++++++++++++ .../ContractGeneratorOperationsTests.cs | 3 + .../InMemoryContractInstanceStoreTests.cs | 121 ++++++ .../Game/Contracts/ContractApiDtos.cs | 95 +++++ .../Game/Contracts/ContractIssueApi.cs | 97 +++++ .../Game/Contracts/ContractListApi.cs | 95 +++++ .../Game/Contracts/IContractInstanceStore.cs | 6 + .../InMemoryContractInstanceStore.cs | 56 +++ .../PostgresContractInstanceStore.cs | 53 +++ .../Game/Quests/QuestProgressApi.cs | 20 +- server/NeonSprawl.Server/Program.cs | 2 + server/README.md | 27 +- 21 files changed, 1213 insertions(+), 11 deletions(-) create mode 100644 bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru create mode 100644 bruno/neon-sprawl-server/contracts/Get contracts default.bru create mode 100644 bruno/neon-sprawl-server/contracts/Issue prototype contract.bru create mode 100644 bruno/neon-sprawl-server/contracts/folder.bru create mode 100644 bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs diff --git a/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru b/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru index 4ac34c8..5cfed57 100644 --- a/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru +++ b/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru @@ -21,7 +21,7 @@ body:json { docs { NEO-149: quest-fixture accepts resetContractInstanceIds — clears contract instance, contract_completion delivery row, and outcome audit for each id (idempotent when absent). - Full issue → clear → GET Bruno loop lands in NEO-151 when contract HTTP ships. + Full issue → clear → GET Bruno loop: `bruno/neon-sprawl-server/contracts/` (NEO-151). Reset below uses placeholder id when no prior issue bru ran in-session. } tests { diff --git a/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru b/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru new file mode 100644 index 0000000..ca8dc11 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru @@ -0,0 +1,136 @@ +meta { + name: GET contracts after encounter clear + type: http + seq: 3 +} + +docs { + NEO-151 AC: issue prototype contract, clear prototype_combat_pocket via three-NPC defeat spine, GET contracts shows completed row with completionRewardSummary. +} + +script:pre-request { + const axios = require("axios"); + const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper"); + const { resetPrototypeCombatTargets } = require("../encounter-progress/scripts/combat-targets-reset-helper.js"); + const { + moveNearPrototypeNpc, + PULSE_COOLDOWN_MS, + MAX_PULSE_DEFEAT_ATTEMPTS, + sleep, + } = require("../encounter-progress/scripts/prototype-npc-bruno-helper.js"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + const slotIndex = 3; + + async function defeatNpc(targetId) { + await moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders); + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId }, + jsonHeaders, + ); + await sleep(bru, PULSE_COOLDOWN_MS); + const castBody = { + schemaVersion: 1, + slotIndex, + abilityId: "prototype_pulse", + targetId, + }; + for (let i = 0; i < MAX_PULSE_DEFEAT_ATTEMPTS; i++) { + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + castBody, + jsonHeaders, + ); + const body = response.data; + if (!body?.accepted || !body?.combatResolution) { + throw new Error( + `cast ${i + 1} vs ${targetId} failed (${body?.reasonCode ?? "no_reason"}): ${JSON.stringify(body)}`, + ); + } + if (body.combatResolution.targetDefeated) { + return; + } + if (i < MAX_PULSE_DEFEAT_ATTEMPTS - 1) { + await sleep(bru, PULSE_COOLDOWN_MS); + } + } + throw new Error( + `did not defeat ${targetId} within ${MAX_PULSE_DEFEAT_ATTEMPTS} pulses`, + ); + } + + await resetAllContractInstances(bru); + await resetPrototypeCombatTargets(bru); + + const issueResponse = await axios.post( + `${baseUrl}/game/players/${playerId}/contracts/issue`, + { + schemaVersion: 1, + playerId, + templateId: "prototype_contract_clear_combat_pocket", + seedBucket: "2026-06-28-neo151-bruno-clear", + }, + jsonHeaders, + ); + if (issueResponse.status !== 200 || !issueResponse.data?.issued) { + throw new Error(`contract issue failed: ${JSON.stringify(issueResponse.data)}`); + } + bru.setVar("neo151ContractInstanceId", issueResponse.data.contract.contractInstanceId); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await defeatNpc("prototype_npc_melee"); + await defeatNpc("prototype_npc_ranged"); + await defeatNpc("prototype_npc_elite"); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/contracts + body: none + auth: none +} + +script:post-response { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const second = await axios.get(`${baseUrl}/game/players/${playerId}/contracts`); + bru.setVar("neo151SecondContractsGetJson", JSON.stringify(second.data)); +} + +tests { + test("completed contract row includes delivery summary", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + const instanceId = bru.getVar("neo151ContractInstanceId"); + const row = body.contracts.find((x) => x.contractInstanceId === instanceId); + expect(row).to.be.an("object"); + expect(row.status).to.equal("completed"); + expect(row.encounterTemplateId).to.equal("prototype_combat_pocket"); + expect(row.completedAt).to.be.a("string"); + expect(row.completionRewardSummary).to.eql({ + itemGrants: [{ itemId: "scrap_metal_bulk", quantity: 5 }], + skillXpGrants: [{ skillId: "salvage", amount: 15 }], + reputationGrants: [], + }); + }); + + test("second GET completionRewardSummary matches first GET", function () { + const first = res.getBody(); + const second = JSON.parse(bru.getVar("neo151SecondContractsGetJson")); + const instanceId = bru.getVar("neo151ContractInstanceId"); + const firstRow = first.contracts.find((x) => x.contractInstanceId === instanceId); + const secondRow = second.contracts.find((x) => x.contractInstanceId === instanceId); + expect(secondRow.completionRewardSummary).to.eql(firstRow.completionRewardSummary); + }); +} diff --git a/bruno/neon-sprawl-server/contracts/Get contracts default.bru b/bruno/neon-sprawl-server/contracts/Get contracts default.bru new file mode 100644 index 0000000..8e9617f --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/Get contracts default.bru @@ -0,0 +1,35 @@ +meta { + name: GET contracts default + type: http + seq: 1 +} + +docs { + NEO-151: fresh dev-local-1 contract list before issue — empty array when no instances. + Pre-request clears any leftover contract rows via quest-fixture reset. +} + +script:pre-request { + const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper"); + await resetAllContractInstances(bru); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/contracts + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); + expect(body.contracts).to.be.an("array"); + }); + + test("no contract instances by default", function () { + expect(res.getBody().contracts).to.eql([]); + }); +} diff --git a/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru b/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru new file mode 100644 index 0000000..bef7147 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru @@ -0,0 +1,56 @@ +meta { + name: Issue prototype contract + type: http + seq: 2 +} + +docs { + NEO-151: POST issue with frozen prototype template + seed bucket; returns active instance with encounter objective id. +} + +script:pre-request { + const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper"); + await resetAllContractInstances(bru); +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/contracts/issue + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "playerId": "{{playerId}}", + "templateId": "prototype_contract_clear_combat_pocket", + "seedBucket": "2026-06-28-neo151-bruno" + } +} + +script:post-response { + const body = res.getBody(); + if (body?.contract?.contractInstanceId) { + bru.setVar("neo151ContractInstanceId", body.contract.contractInstanceId); + } +} + +tests { + test("returns 200 with issued true", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.issued).to.equal(true); + expect(body.reasonCode).to.equal(undefined); + }); + + test("active contract row includes encounter objective id", function () { + const contract = res.getBody().contract; + expect(contract).to.be.an("object"); + expect(contract.status).to.equal("active"); + expect(contract.templateId).to.equal("prototype_contract_clear_combat_pocket"); + expect(contract.encounterTemplateId).to.equal("prototype_combat_pocket"); + expect(contract.contractInstanceId).to.be.a("string"); + expect(contract.completionRewardSummary).to.equal(undefined); + }); +} diff --git a/bruno/neon-sprawl-server/contracts/folder.bru b/bruno/neon-sprawl-server/contracts/folder.bru new file mode 100644 index 0000000..30eea97 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/folder.bru @@ -0,0 +1,8 @@ +meta { + name: contracts +} + +docs { + NEO-151: contract issue POST + per-player contract GET. + Cross-link: bruno/neon-sprawl-server/encounter-progress/ for encounter clear spine. +} diff --git a/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js b/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js new file mode 100644 index 0000000..18cda66 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js @@ -0,0 +1,30 @@ +async function resetAllContractInstances(bru) { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + + const listResponse = await axios.get( + `${baseUrl}/game/players/${playerId}/contracts`, + jsonHeaders, + ); + if (listResponse.status !== 200) { + throw new Error(`contracts GET failed: ${listResponse.status}`); + } + + const ids = (listResponse.data.contracts || []).map((row) => row.contractInstanceId); + if (ids.length === 0) { + return; + } + + await axios.post( + `${baseUrl}/game/players/${playerId}/__dev/quest-fixture`, + { + schemaVersion: 1, + resetContractInstanceIds: ids, + }, + jsonHeaders, + ); +} + +module.exports = { resetAllContractInstances }; diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index e16f9b3..0076acf 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -67,6 +67,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies. **Contract completion wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — encounter clear completes matching active instance + outcome audit; [server README — Contract completion operations](../../../server/README.md#contract-completion-operations-neo-149). +**Contract HTTP (NEO-151):** **`POST …/contracts/issue`** + **`GET …/contracts`** — issue envelope + per-player list with **`encounterTemplateId`** and **`completionRewardSummary`** on completed rows; Bruno `bruno/neon-sprawl-server/contracts/`; [server README — Contract issue HTTP (NEO-151)](../../../server/README.md#contract-issue-http-neo-151), [Per-player contract list HTTP (NEO-151)](../../../server/README.md#per-player-contract-list-http-neo-151); plan [NEO-151](../../plans/NEO-151-implementation-plan.md). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index c292b62..00b4074 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -62,7 +62,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | | E7.M3 | Ready | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08 quest HTTP projections landed ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)):** world GET **`factionGateRules`** + quest-progress **`completionRewardSummary.reputationGrants`**; Bruno quest-definitions + operator-chain progress bru ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115), [Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119). **E7M3-09 telemetry landed ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)):** comment-only **`reputation_delta`** + **`faction_gate_blocked`** hook sites in ops layers ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141). **E7M3-10 client faction HUD landed ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)):** `faction_standing_client.gd`, standing label + gate deny + rep reward lines ([NEO-142 plan](../../plans/NEO-142-implementation-plan.md)); [client README — Faction standing + gate feedback HUD (NEO-142)](../../../client/README.md#faction-standing--gate-feedback-hud-neo-142); [`NEO-142` manual QA](../../manual-qa/NEO-142.md). **E7M3-11 client capstone landed ([NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)):** playable faction reputation + gate capstone — [`NEO-143` manual QA](../../manual-qa/NEO-143.md); [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143); plan [NEO-143](../../plans/NEO-143-implementation-plan.md). **Epic 7 Slice 3 client capstone complete.** Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**. Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [NEO-140 plan](../../plans/NEO-140-implementation-plan.md), [NEO-141 plan](../../plans/NEO-141-implementation-plan.md), [NEO-142 plan](../../plans/NEO-142-implementation-plan.md), [NEO-143 plan](../../plans/NEO-143-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | -| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M4-06 completion wiring landed ([NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)):** `ContractCompletionOperations.TryCompleteOnEncounterClear`, encounter hook after E5.M3 loot + quest wiring, `QuestFixtureRequest.resetContractInstanceIds` ([NEO-149 plan](../../plans/NEO-149-implementation-plan.md)); [server README — Contract completion operations (NEO-149)](../../../server/README.md#contract-completion-operations-neo-149); Bruno `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru`. **E7M4-07 economy validation landed ([NEO-150](https://linear.app/neon-sprawl/issue/NEO-150)):** `ContractEconomyValidation.TryValidateTemplate` at issue — band caps + cross-ref deny codes ([NEO-150 plan](../../plans/NEO-150-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), [NEO-149](../../plans/NEO-149-implementation-plan.md), [NEO-150](../../plans/NEO-150-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | +| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M4-06 completion wiring landed ([NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)):** `ContractCompletionOperations.TryCompleteOnEncounterClear`, encounter hook after E5.M3 loot + quest wiring, `QuestFixtureRequest.resetContractInstanceIds` ([NEO-149 plan](../../plans/NEO-149-implementation-plan.md)); [server README — Contract completion operations (NEO-149)](../../../server/README.md#contract-completion-operations-neo-149); Bruno `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru`. **E7M4-07 economy validation landed ([NEO-150](https://linear.app/neon-sprawl/issue/NEO-150)):** `ContractEconomyValidation.TryValidateTemplate` at issue — band caps + cross-ref deny codes ([NEO-150 plan](../../plans/NEO-150-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-08 contract HTTP landed ([NEO-151](https://linear.app/neon-sprawl/issue/NEO-151)):** `POST …/contracts/issue` + `GET …/contracts` — issue envelope, active + recent completed list, `completionRewardSummary` on completed rows ([NEO-151 plan](../../plans/NEO-151-implementation-plan.md)); [server README — Contract issue HTTP (NEO-151)](../../../server/README.md#contract-issue-http-neo-151); Bruno `bruno/neon-sprawl-server/contracts/`. **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), [NEO-149](../../plans/NEO-149-implementation-plan.md), [NEO-150](../../plans/NEO-150-implementation-plan.md), [NEO-151](../../plans/NEO-151-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | --- diff --git a/docs/plans/NEO-151-implementation-plan.md b/docs/plans/NEO-151-implementation-plan.md index 0ad11ca..0af6c1f 100644 --- a/docs/plans/NEO-151-implementation-plan.md +++ b/docs/plans/NEO-151-implementation-plan.md @@ -48,9 +48,18 @@ Client-readable contract issuance and state without local math — **`POST …/c ## 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. +- [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 @@ -186,7 +195,7 @@ Manual Godot QA: **none** (server HTTP; capstone **NEO-154**). Bruno: **`contrac | 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` | +| 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` | diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs new file mode 100644 index 0000000..d0beca4 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs @@ -0,0 +1,361 @@ +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public sealed class ContractApiTests +{ + private const string PlayerId = "dev-local-1"; + private const string TemplateId = "prototype_contract_clear_combat_pocket"; + private const string EncounterId = "prototype_combat_pocket"; + private const string SeedBucket = "2026-06-28-neo151"; + private const string DisplayName = "Clear Combat Pocket (Repeat)"; + private static readonly DateTimeOffset CompletedAt = new(2026, 6, 28, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + "/game/players/missing-player/contracts/issue", + JsonContent.Create(CreateIssueRequest("missing-player"))); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnBadRequest_WhenPlayerIdMismatch() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest("other-player"))); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnBadRequest_WhenSchemaVersionMismatch() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create( + new ContractIssueRequest + { + SchemaVersion = 99, + PlayerId = PlayerId, + TemplateId = TemplateId, + SeedBucket = SeedBucket, + })); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnIssuedTrue_WhenPrototypeTemplateEligible() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId( + PlayerId, + TemplateId, + SeedBucket); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(ContractIssueResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.True(body.Issued); + Assert.Null(body.ReasonCode); + Assert.NotNull(body.Contract); + Assert.Equal(expectedInstanceId, body.Contract!.ContractInstanceId); + Assert.Equal(TemplateId, body.Contract.TemplateId); + Assert.Equal(DisplayName, body.Contract.TemplateDisplayName); + Assert.Equal(ContractListApi.StatusActive, body.Contract.Status); + Assert.Equal(EncounterId, body.Contract.EncounterTemplateId); + Assert.Equal(SeedBucket, body.Contract.SeedBucket); + Assert.Null(body.Contract.CompletedAt); + Assert.Null(body.Contract.CompletionRewardSummary); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnIssuedFalse_WhenActiveContractExists() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var first = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create( + new ContractIssueRequest + { + SchemaVersion = ContractIssueRequest.CurrentSchemaVersion, + PlayerId = PlayerId, + TemplateId = TemplateId, + SeedBucket = "2026-06-28-neo151-second", + })); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Issued); + Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, body.ReasonCode); + Assert.NotNull(body.Contract); + Assert.Equal(ContractListApi.StatusActive, body.Contract!.Status); + } + + [Fact] + public async Task GetContracts_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/missing-player/contracts"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetContracts_ShouldReturnEmptyList_WhenNoInstances() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(ContractListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal(PlayerId, body.PlayerId); + Assert.Empty(body.Contracts); + } + + [Fact] + public async Task GetContracts_ShouldReturnActiveRowAfterIssue_WithEncounterObjectiveId() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var issue = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + Assert.Equal(HttpStatusCode.OK, issue.StatusCode); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Contracts); + Assert.Equal(ContractListApi.StatusActive, row.Status); + Assert.Equal(EncounterId, row.EncounterTemplateId); + Assert.Null(row.CompletionRewardSummary); + } + + [Fact] + public async Task GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var deps = ResolveDependencies(factory); + var issue = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + Assert.Equal(HttpStatusCode.OK, issue.StatusCode); + var issueBody = await issue.Content.ReadFromJsonAsync(); + Assert.NotNull(issueBody?.Contract); + var instanceId = issueBody!.Contract!.ContractInstanceId; + + // Act + var completed = ContractCompletionOperations.TryCompleteOnEncounterClear( + PlayerId, + EncounterId, + deps.InstanceStore, + deps.OutcomeStore, + deps.TemplateRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, + deps.TimeProvider); + var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + + // Assert + Assert.True(completed.Success); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Contracts); + Assert.Equal(instanceId, row.ContractInstanceId); + Assert.Equal(ContractListApi.StatusCompleted, row.Status); + Assert.NotNull(row.CompletedAt); + AssertPrototypeCompletionRewardSummary(row.CompletionRewardSummary); + } + + [Fact] + public async Task GetContracts_ShouldReturnSameCompletionRewardSummary_WhenReplayed() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var deps = ResolveDependencies(factory); + _ = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + _ = ContractCompletionOperations.TryCompleteOnEncounterClear( + PlayerId, + EncounterId, + deps.InstanceStore, + deps.OutcomeStore, + deps.TemplateRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, + deps.TimeProvider); + + // Act + var first = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + var second = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + + // Assert + var firstBody = await first.Content.ReadFromJsonAsync(); + var secondBody = await second.Content.ReadFromJsonAsync(); + Assert.NotNull(firstBody); + Assert.NotNull(secondBody); + AssertCompletionRewardSummariesEqual( + Assert.Single(firstBody!.Contracts).CompletionRewardSummary, + Assert.Single(secondBody!.Contracts).CompletionRewardSummary); + } + + private static ContractIssueRequest CreateIssueRequest(string playerId) => + new() + { + SchemaVersion = ContractIssueRequest.CurrentSchemaVersion, + PlayerId = playerId, + TemplateId = TemplateId, + SeedBucket = SeedBucket, + }; + + private static void AssertPrototypeCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary) + { + Assert.NotNull(summary); + var item = Assert.Single(summary!.ItemGrants); + Assert.Equal("scrap_metal_bulk", item.ItemId); + Assert.Equal(5, item.Quantity); + var skill = Assert.Single(summary.SkillXpGrants); + Assert.Equal("salvage", skill.SkillId); + Assert.Equal(15, skill.Amount); + Assert.Empty(summary.ReputationGrants); + } + + private static void AssertCompletionRewardSummariesEqual( + QuestCompletionRewardSummaryJson? first, + QuestCompletionRewardSummaryJson? second) + { + Assert.NotNull(first); + Assert.NotNull(second); + AssertPrototypeCompletionRewardSummary(first); + AssertPrototypeCompletionRewardSummary(second); + } + + private static ApiTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) + { + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + return new ApiTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + new FakeTimeProvider(CompletedAt)); + } + + private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } + + private sealed record ApiTestDependencies( + IContractTemplateRegistry TemplateRegistry, + IContractInstanceStore InstanceStore, + IContractOutcomeStore OutcomeStore, + IItemDefinitionRegistry ItemRegistry, + IPlayerInventoryStore InventoryStore, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore SkillProgressionStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore, + TimeProvider TimeProvider); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs index fca68a5..bd8e527 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs @@ -407,5 +407,8 @@ public sealed class ContractGeneratorOperationsTests public bool TryClearInstance(string playerId, string contractInstanceId) => inner.TryClearInstance(playerId, contractInstanceId); + + public IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows) => + inner.ListForPlayer(playerId, maxCompletedRows); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs index cf6d087..707980d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs @@ -312,6 +312,127 @@ public sealed class InMemoryContractInstanceStoreTests second.Status == ContractInstanceStatus.Active); } + [Fact] + public void ListForPlayer_ShouldReturnEmpty_WhenPlayerHasNoInstances() + { + // Arrange + var store = CreateStore(); + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + // Assert + Assert.Empty(rows); + } + + [Fact] + public void ListForPlayer_ShouldReturnEmpty_ForUnknownPlayer() + { + // Arrange + var store = CreateStore(); + // Act + var rows = store.ListForPlayer(UnknownPlayerId, maxCompletedRows: 10); + // Assert + Assert.Empty(rows); + } + + [Fact] + public void ListForPlayer_ShouldReturnActiveOnly_WhenNoCompletedRows() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + // Assert + var row = Assert.Single(rows); + Assert.Equal(InstanceId, row.ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, row.Status); + } + + [Fact] + public void ListForPlayer_ShouldReturnCompletedOrderedByIssuedAt_WhenNoActive() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + Assert.True(store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _)); + Assert.True(store.TryCreateActive( + PlayerId, + SecondInstanceId, + TemplateId, + SeedBucket, + IssuedAt.AddDays(1), + out _)); + Assert.True(store.TryMarkComplete( + PlayerId, + SecondInstanceId, + CompletedAt.AddDays(1), + out _)); + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + // Assert + Assert.Equal(2, rows.Count); + Assert.Equal(SecondInstanceId, rows[0].ContractInstanceId); + Assert.Equal(InstanceId, rows[1].ContractInstanceId); + } + + [Fact] + public void ListForPlayer_ShouldPrependActiveAndCapCompletedRows() + { + // Arrange + var store = CreateStore(); + for (var i = 0; i < 12; i++) + { + var instanceId = $"prototype_contract_instance_done_{i:D2}"; + Assert.True(store.TryCreateActive( + PlayerId, + instanceId, + TemplateId, + SeedBucket, + IssuedAt.AddHours(i), + out _)); + Assert.True(store.TryMarkComplete( + PlayerId, + instanceId, + CompletedAt.AddHours(i), + out _)); + } + + const string activeId = "prototype_contract_instance_active"; + Assert.True(store.TryCreateActive( + PlayerId, + activeId, + TemplateId, + SeedBucket, + IssuedAt.AddDays(3), + out _)); + + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + // Assert + Assert.Equal(11, rows.Count); + Assert.Equal(activeId, rows[0].ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, rows[0].Status); + Assert.All(rows.Skip(1), row => Assert.Equal(ContractInstanceStatus.Completed, row.Status)); + Assert.DoesNotContain( + rows.Skip(1), + row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_done_00", StringComparison.Ordinal)); + Assert.DoesNotContain( + rows.Skip(1), + row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_done_01", StringComparison.Ordinal)); + } + [Fact] public void TryClearInstance_ShouldReturnTrue_WhenRowMissing() { diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs b/server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs new file mode 100644 index 0000000..3aa9789 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization; +using NeonSprawl.Server.Game.Quests; + +namespace NeonSprawl.Server.Game.Contracts; + +/// POST body for POST /game/players/{{id}}/contracts/issue (NEO-151). +public sealed class ContractIssueRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("playerId")] + public string? PlayerId { get; init; } + + [JsonPropertyName("templateId")] + public string? TemplateId { get; init; } + + [JsonPropertyName("seedBucket")] + public string? SeedBucket { get; init; } + + [JsonPropertyName("zoneDifficultyBand")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ZoneDifficultyBand { get; init; } +} + +/// POST response for contract issue (NEO-151). +public sealed class ContractIssueResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("issued")] + public bool Issued { get; init; } + + [JsonPropertyName("reasonCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasonCode { get; init; } + + [JsonPropertyName("contract")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ContractInstanceRowJson? Contract { get; init; } +} + +/// JSON body for GET /game/players/{{id}}/contracts (NEO-151). +public sealed class ContractListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + [JsonPropertyName("contracts")] + public required IReadOnlyList Contracts { get; init; } +} + +/// Authoritative contract instance projection for HTTP clients (NEO-151). +public sealed class ContractInstanceRowJson +{ + [JsonPropertyName("contractInstanceId")] + public required string ContractInstanceId { get; init; } + + [JsonPropertyName("templateId")] + public required string TemplateId { get; init; } + + [JsonPropertyName("templateDisplayName")] + public required string TemplateDisplayName { get; init; } + + /// active or completed. + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("encounterTemplateId")] + public required string EncounterTemplateId { get; init; } + + [JsonPropertyName("seedBucket")] + public required string SeedBucket { get; init; } + + [JsonPropertyName("issuedAt")] + public DateTimeOffset IssuedAt { get; init; } + + [JsonPropertyName("completedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CompletedAt { get; init; } + + [JsonPropertyName("completionRewardSummary")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs b/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs new file mode 100644 index 0000000..4877e38 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs @@ -0,0 +1,97 @@ +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Maps POST /game/players/{{id}}/contracts/issue (NEO-151). +public static class ContractIssueApi +{ + public static WebApplication MapContractIssueApi(this WebApplication app) + { + app.MapPost( + "/game/players/{id}/contracts/issue", + (string id, ContractIssueRequest? body, IPositionStateStore positions, + IContractTemplateRegistry templateRegistry, IEncounterDefinitionRegistry encounterRegistry, + IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry, + IFactionDefinitionRegistry factionRegistry, IContractInstanceStore instanceStore, + IFactionStandingStore standingStore, IRewardDeliveryStore deliveryStore, + TimeProvider timeProvider) => + { + if (body is null || body.SchemaVersion != ContractIssueRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(body.TemplateId); + var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(body.SeedBucket); + var normalizedBodyPlayerId = ContractInstanceIds.NormalizePlayerId(body.PlayerId); + if (normalizedTemplateId.Length == 0 || + normalizedSeedBucket.Length == 0 || + normalizedBodyPlayerId.Length == 0) + { + return Results.BadRequest(); + } + + var trimmedId = id.Trim(); + var normalizedPathPlayerId = ContractInstanceIds.NormalizePlayerId(trimmedId); + if (normalizedPathPlayerId.Length == 0 || + !string.Equals(normalizedBodyPlayerId, normalizedPathPlayerId, StringComparison.Ordinal)) + { + return Results.BadRequest(); + } + + if (!positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + var result = ContractGeneratorOperations.TryIssue( + trimmedId, + normalizedTemplateId, + normalizedSeedBucket, + body.ZoneDifficultyBand, + templateRegistry, + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry, + instanceStore, + standingStore, + timeProvider); + + return Results.Json( + MapIssueResponse(trimmedId, result, templateRegistry, deliveryStore)); + }); + + return app; + } + + internal static ContractIssueResponse MapIssueResponse( + string playerId, + ContractIssueOperationResult result, + IContractTemplateRegistry templateRegistry, + IRewardDeliveryStore deliveryStore) + { + ContractInstanceRowJson? contract = null; + if (result.Snapshot is { } snapshot) + { + contract = ContractListApi.MapInstanceRow( + playerId, + snapshot, + templateRegistry, + deliveryStore, + result.EncounterTemplateId); + } + + return new ContractIssueResponse + { + Issued = result.Success, + ReasonCode = result.ReasonCode, + Contract = contract, + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs b/server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs new file mode 100644 index 0000000..c5253ef --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs @@ -0,0 +1,95 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Maps GET /game/players/{{id}}/contracts (NEO-151). +public static class ContractListApi +{ + public const int MaxCompletedRows = 10; + + public const string StatusActive = "active"; + public const string StatusCompleted = "completed"; + + public static WebApplication MapContractListApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/contracts", + (string id, IPositionStateStore positions, IContractInstanceStore instanceStore, + IContractTemplateRegistry templateRegistry, IRewardDeliveryStore deliveryStore) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + var snapshots = instanceStore.ListForPlayer(trimmedId, MaxCompletedRows); + var rows = new List(snapshots.Count); + foreach (var snapshot in snapshots) + { + rows.Add(MapInstanceRow(trimmedId, snapshot, templateRegistry, deliveryStore)); + } + + return Results.Json( + new ContractListResponse + { + PlayerId = trimmedId, + Contracts = rows, + }); + }); + + return app; + } + + internal static ContractInstanceRowJson MapInstanceRow( + string playerId, + ContractInstanceState snapshot, + IContractTemplateRegistry templateRegistry, + IRewardDeliveryStore deliveryStore, + string? encounterTemplateIdOverride = null) + { + var templateDisplayName = snapshot.TemplateId; + var encounterTemplateId = encounterTemplateIdOverride ?? string.Empty; + if (templateRegistry.TryGetDefinition(snapshot.TemplateId, out var template)) + { + templateDisplayName = template.DisplayName; + if (encounterTemplateId.Length == 0) + { + encounterTemplateId = template.EncounterTemplateId; + } + } + + QuestCompletionRewardSummaryJson? completionRewardSummary = null; + if (snapshot.Status == ContractInstanceStatus.Completed) + { + completionRewardSummary = QuestProgressApi.MapCompletionRewardSummary( + playerId, + RewardDeliverySourceKinds.ContractCompletion, + snapshot.ContractInstanceId, + deliveryStore); + } + + return new ContractInstanceRowJson + { + ContractInstanceId = snapshot.ContractInstanceId, + TemplateId = snapshot.TemplateId, + TemplateDisplayName = templateDisplayName, + Status = MapStatus(snapshot.Status), + EncounterTemplateId = encounterTemplateId, + SeedBucket = snapshot.SeedBucket, + IssuedAt = snapshot.IssuedAt, + CompletedAt = snapshot.CompletedAt, + CompletionRewardSummary = completionRewardSummary, + }; + } + + private static string MapStatus(ContractInstanceStatus status) => + status switch + { + ContractInstanceStatus.Active => StatusActive, + ContractInstanceStatus.Completed => StatusCompleted, + _ => throw new InvalidOperationException($"Unknown contract instance status '{status}'."), + }; +} diff --git a/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs index 6bb48b6..7364700 100644 --- a/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs @@ -36,4 +36,10 @@ public interface IContractInstanceStore /// Dev fixture only — removes one instance row; idempotent when absent. bool TryClearInstance(string playerId, string contractInstanceId); + + /// + /// Active row first (when present), then up to completed rows + /// for the player ordered by descending. + /// + IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows); } diff --git a/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs index 36591ca..1cac067 100644 --- a/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs @@ -240,6 +240,62 @@ public sealed class InMemoryContractInstanceStore(IOptions } } + /// + public IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows) + { + var player = ContractInstanceIds.NormalizePlayerId(playerId); + if (player.Length == 0 || !knownPlayers.Contains(player) || maxCompletedRows < 0) + { + return []; + } + + lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object())) + { + ContractInstanceState? active = null; + if (activeInstanceByPlayer.TryGetValue(player, out var activeId) && + byInstanceId.TryGetValue(activeId, out var activeRow) && + activeRow.Status == ContractInstanceStatus.Active && + string.Equals(activeRow.PlayerId, player, StringComparison.Ordinal)) + { + active = activeRow.ToSnapshot(); + } + + var completed = new List(); + foreach (var row in byInstanceId.Values) + { + if (!string.Equals(row.PlayerId, player, StringComparison.Ordinal) || + row.Status != ContractInstanceStatus.Completed) + { + continue; + } + + completed.Add(row.ToSnapshot()); + } + + completed.Sort(static (a, b) => + { + var cmp = b.IssuedAt.CompareTo(a.IssuedAt); + return cmp != 0 + ? cmp + : string.Compare(a.ContractInstanceId, b.ContractInstanceId, StringComparison.Ordinal); + }); + + if (completed.Count > maxCompletedRows) + { + completed.RemoveRange(maxCompletedRows, completed.Count - maxCompletedRows); + } + + if (active is null) + { + return completed; + } + + var result = new List(1 + completed.Count) { active }; + result.AddRange(completed); + return result; + } + } + /// public bool TryClearInstance(string playerId, string contractInstanceId) { diff --git a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs index 99945fc..d4f481e 100644 --- a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs @@ -314,6 +314,59 @@ public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSo return true; } + /// + public IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows) + { + var player = ContractInstanceIds.NormalizePlayerId(playerId); + if (player.Length == 0 || maxCompletedRows < 0) + { + return []; + } + + PostgresContractInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + if (!PlayerExists(conn, player)) + { + return []; + } + + ContractInstanceState? active = null; + if (TryGetActiveForPlayer(player, out var activeSnapshot)) + { + active = activeSnapshot; + } + + var completed = new List(maxCompletedRows); + if (maxCompletedRows > 0) + { + using var cmd = new Npgsql.NpgsqlCommand( + """ + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM contract_instance + WHERE player_id = @pid AND status = 'completed' + ORDER BY issued_at DESC, contract_instance_id ASC + LIMIT @limit; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + cmd.Parameters.AddWithValue("limit", maxCompletedRows); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + completed.Add(ReadSnapshot(reader)); + } + } + + if (active is null) + { + return completed; + } + + var result = new List(1 + completed.Count) { active }; + result.AddRange(completed); + return result; + } + /// public bool TryClearInstance(string playerId, string contractInstanceId) { diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs index bd35646..c1c313b 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs @@ -121,20 +121,34 @@ public static class QuestProgressApi private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary( string playerId, string questId, + IRewardDeliveryStore deliveryStore) => + MapCompletionRewardSummary( + playerId, + RewardDeliverySourceKinds.QuestCompletion, + questId, + deliveryStore); + + internal static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary( + string playerId, + string sourceKind, + string sourceId, IRewardDeliveryStore deliveryStore) { - if (!deliveryStore.TryGet(playerId, questId, out var deliveryEvent)) + if (!deliveryStore.TryGet(playerId, sourceKind, sourceId, out var deliveryEvent)) { return null; } - return new QuestCompletionRewardSummaryJson + return MapCompletionRewardSummary(deliveryEvent); + } + + internal static QuestCompletionRewardSummaryJson MapCompletionRewardSummary(RewardDeliveryEvent deliveryEvent) => + new() { ItemGrants = MapItemGrants(deliveryEvent.GrantedItems), SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp), ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation), }; - } /// Preserves commit-time grant order from . private static List MapItemGrants(IReadOnlyList grants) diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index c37a3f8..6990680 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -90,6 +90,8 @@ app.MapFactionStandingApi(); app.MapEncounterProgressApi(); app.MapQuestProgressApi(); app.MapQuestAcceptApi(); +app.MapContractIssueApi(); +app.MapContractListApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") || diff --git a/server/README.md b/server/README.md index 57d5db3..ae6b3a4 100644 --- a/server/README.md +++ b/server/README.md @@ -281,6 +281,7 @@ Per-player contract runtime state lives in **`IContractInstanceStore`**, keyed b - **`TryCreateActive`** — first active for player returns `true`; denies when another active exists, duplicate instance id, or player not writable. - **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**. - **`TryClearInstance`** — dev fixture only; removes one instance row. +- **`ListForPlayer`** — active row first (when present), then up to N recent **completed** rows ordered by **`issuedAt`** descending (NEO-151 HTTP read path). Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation and selection run in **`ContractGeneratorOperations`** (NEO-147). @@ -316,7 +317,29 @@ Completed instances are immutable (no status regression). Player, template, and **Result:** **`ContractIssueOperationResult`** — `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, **`encounterTemplateId`** from the selected template. -HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md), [NEO-150 implementation plan](../../docs/plans/NEO-150-implementation-plan.md). +## Contract issue HTTP (NEO-151) + +**`POST /game/players/{id}/contracts/issue`** issues an **`active`** contract via **`ContractGeneratorOperations.TryIssue`**. Body (**`schemaVersion` 1**): **`playerId`** (must match path `{id}` after normalize), **`templateId`**, **`seedBucket`**, optional **`zoneDifficultyBand`**. Response: **`issued`**, optional **`reasonCode`**, optional **`contract`** row projection. + +**Deny semantics:** **200** with **`issued: false`** + stable **`reasonCode`** (same codes as generator operations). **`active_contract_exists`** may echo the existing active **`contract`** row. + +```bash +curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/contracts/issue" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"playerId":"dev-local-1","templateId":"prototype_contract_clear_combat_pocket","seedBucket":"2026-06-28"}' +``` + +Bruno: `bruno/neon-sprawl-server/contracts/`. Plan: [NEO-151 implementation plan](../../docs/plans/NEO-151-implementation-plan.md). + +## Per-player contract list HTTP (NEO-151) + +**`GET /game/players/{id}/contracts`** returns **`schemaVersion` 1**, **`playerId`**, and **`contracts`**: the player's active instance (if any) plus up to **10** recent **completed** rows (**`issuedAt`** desc). Each row includes **`templateId`**, **`templateDisplayName`**, **`status`** (`active` \| `completed`), **`encounterTemplateId`**, **`seedBucket`**, **`issuedAt`**, optional **`completedAt`**, and optional **`completionRewardSummary`** (item/skill XP/reputation grant lines from **`IRewardDeliveryStore`** when **`contract_completion`** delivery exists — same nested shape as quest progress NEO-129/NEO-140). + +```bash +curl -sS "http://localhost:5253/game/players/dev-local-1/contracts" +``` + +Unknown player ids return **404** (position gate). Bruno: `bruno/neon-sprawl-server/contracts/`. ## Contract completion operations (NEO-149) @@ -347,7 +370,7 @@ HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-1 **Prototype edge case:** issuing a contract after the bound encounter is already cleared leaves the instance **`active`** with no re-trigger in v1 — prototype flow is issue-then-clear. -HTTP contract GET/issue projections land in **NEO-151** (E7M4-08). Plan: [NEO-149 implementation plan](../../docs/plans/NEO-149-implementation-plan.md). +Plan: [NEO-149 implementation plan](../../docs/plans/NEO-149-implementation-plan.md). Contract HTTP GET/issue: [Per-player contract list HTTP (NEO-151)](#per-player-contract-list-http-neo-151). ## Contract outcome store (NEO-146) From f6ef0a62272155a69855a3eee3938bd963280050 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 10:23:31 -0400 Subject: [PATCH 3/7] NEO-151: Add code review for contract issue and list HTTP API --- docs/reviews/2026-06-28-NEO-151.md | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/reviews/2026-06-28-NEO-151.md diff --git a/docs/reviews/2026-06-28-NEO-151.md b/docs/reviews/2026-06-28-NEO-151.md new file mode 100644 index 0000000..d87359f --- /dev/null +++ b/docs/reviews/2026-06-28-NEO-151.md @@ -0,0 +1,60 @@ +# Code review — NEO-151 contract issue POST + per-player contract GET + +**Date:** 2026-06-28 +**Scope:** Branch `NEO-151-e7m4-08-contract-issue-post-per-player-contract-get` — commits `9994aee`..`c0252ee` (2 commits) +**Base:** `main` +**Issue:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) — E7M4-08 contract issue POST + per-player contract GET + +## Verdict + +**Approve with nits** + +## Summary + +This branch lands client-readable contract HTTP: **`POST …/contracts/issue`** delegates to **`ContractGeneratorOperations.TryIssue`** with the quest-accept-style **`{ issued, reasonCode, contract }`** envelope; **`GET …/contracts`** lists the active instance (if any) plus up to 10 completed rows with **`encounterTemplateId`** and **`completionRewardSummary`** on completed rows via shared **`QuestProgressApi.MapCompletionRewardSummary`**. + +Store work adds **`IContractInstanceStore.ListForPlayer`** to both in-memory and Postgres implementations with active-first ordering and **`issuedAt` desc** cap semantics. Integration coverage is strong (10 HTTP tests + 5 store list tests); Bruno **`contracts/`** folder includes a full issue → encounter clear → GET capstone. Full suite passes (**927** tests). Risk is low for prototype scope; residual gaps are HTTP edge-case consistency and optional Postgres list persistence coverage. + +## Documentation checked + +| Document | Result | +|----------|--------| +| `docs/plans/NEO-151-implementation-plan.md` | **Matches** — AC checklist complete; shipped reconciliation matches diff; kickoff decisions (cap 10, issue envelope, reward summary reuse, store boundary) reflected in code | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-151 HTTP anchor + README links added | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row extended with E7M4-08 / NEO-151 | +| `docs/decomposition/modules/module_dependency_register.md` | **Not re-read in diff** — no register change required; E7.M4 already tracked | +| `server/README.md` | **Matches** — contract issue/list sections, **`ListForPlayer`** semantics, curl samples | +| `docs/plans/E7M4-pre-production-backlog.md` | **Not re-read in diff** — plan cites E7M4-08; scope satisfied | +| Client counterpart (NEO-153) | **N/A** — correctly out of scope; plan does not claim player-visible slice complete | + +Register/tracking table updates are included in this branch. + +## Blocking issues + +None. + +## Suggestions + +1. **Align empty path `{id}` on POST with sibling player APIs** — `ContractIssueApi` returns **400** when the path id normalizes to empty (whitespace-only), while `ContractListApi`, `QuestAcceptApi`, and most other player routes return **404** for `trimmedId.Length == 0`. Recommend matching the **404** position-gate pattern for consistency. + +2. **Add POST 400 tests for missing seed fields** — Handler rejects empty **`templateId`** / **`seedBucket`** (lines 32–36 in `ContractIssueApi.cs`), but `ContractApiTests` only covers schema version and **`playerId`** mismatch. One test each (or a `[Theory]`) would lock the validation gate. + +3. **Optional Postgres `ListForPlayer` round-trip** — Plan marks this optional; if CI Postgres is stable, a `[RequirePostgresFact]` mirroring NEO-146 store tests would guard SQL ordering/cap behavior separately from in-memory parity. + +## Nits + +- Nit: **`PostgresContractInstanceStore.ListForPlayer`** opens a connection then calls **`TryGetActiveForPlayer`**, which opens a second connection. Fine at prototype volume; could inline the active SELECT on the existing connection later. +- Nit: **`GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear`** invokes **`TryCompleteOnEncounterClear`** under **`// Act`** before the GET — consider moving completion into **`// Arrange`** so Act is HTTP-only (AAA clarity). +- Nit: Plan §1 Postgres snippet mentions **`completed_at DESC`** but kickoff + README + implementation use **`issuedAt` desc** — update the plan technical section on merge to remove the stale SQL note (implementation is correct). + +## Verification + +```bash +dotnet test NeonSprawl.sln +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractApiTests" +``` + +Manual (dev server): + +- Run Bruno folder `bruno/neon-sprawl-server/contracts/` — especially **Get contracts after encounter clear** capstone. +- Smoke **`POST …/contracts/issue`** deny path: second issue while active → **`issued: false`**, **`active_contract_exists`**. From 0888262f138d246365ba2e8a134e626e1db61be0 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 10:25:58 -0400 Subject: [PATCH 4/7] NEO-151: Address code review findings for contract HTTP API Return 404 for whitespace path on issue POST, add seed-field validation tests, inline Postgres ListForPlayer active query, Postgres list test, Bruno whitespace-path 404 check, and strike through resolved review items. --- ...OST contract issue whitespace path 404.bru | 30 +++++++++++ docs/plans/NEO-151-implementation-plan.md | 8 +-- docs/reviews/2026-06-28-NEO-151.md | 12 ++--- .../Game/Contracts/ContractApiTests.cs | 51 +++++++++++++++++-- ...ractInstancePersistenceIntegrationTests.cs | 49 ++++++++++++++++++ .../Game/Contracts/ContractIssueApi.cs | 15 +++--- .../PostgresContractInstanceStore.cs | 20 +++++++- 7 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 bruno/neon-sprawl-server/contracts/POST contract issue whitespace path 404.bru diff --git a/bruno/neon-sprawl-server/contracts/POST contract issue whitespace path 404.bru b/bruno/neon-sprawl-server/contracts/POST contract issue whitespace path 404.bru new file mode 100644 index 0000000..6e58051 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/POST contract issue whitespace path 404.bru @@ -0,0 +1,30 @@ +meta { + name: POST contract issue whitespace path 404 + type: http + seq: 4 +} + +docs { + NEO-151 review: whitespace-only path id returns 404 (position gate), matching quest accept and contract GET. +} + +post { + url: {{baseUrl}}/game/players/%20/contracts/issue + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "playerId": "{{playerId}}", + "templateId": "prototype_contract_clear_combat_pocket", + "seedBucket": "2026-06-28-neo151-bruno" + } +} + +tests { + test("returns 404 for whitespace path id", function () { + expect(res.getStatus()).to.equal(404); + }); +} diff --git a/docs/plans/NEO-151-implementation-plan.md b/docs/plans/NEO-151-implementation-plan.md index 0af6c1f..e2cf468 100644 --- a/docs/plans/NEO-151-implementation-plan.md +++ b/docs/plans/NEO-151-implementation-plan.md @@ -57,7 +57,7 @@ Client-readable contract issuance and state without local math — **`POST …/c - **`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. +- **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. @@ -76,7 +76,7 @@ IReadOnlyList ListForPlayer(string playerId, int maxCompl - 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. +**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. @@ -102,8 +102,8 @@ Unit tests in **`InMemoryContractInstanceStoreTests`**: empty player, active onl **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). +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`**). diff --git a/docs/reviews/2026-06-28-NEO-151.md b/docs/reviews/2026-06-28-NEO-151.md index d87359f..74fed77 100644 --- a/docs/reviews/2026-06-28-NEO-151.md +++ b/docs/reviews/2026-06-28-NEO-151.md @@ -35,17 +35,17 @@ None. ## Suggestions -1. **Align empty path `{id}` on POST with sibling player APIs** — `ContractIssueApi` returns **400** when the path id normalizes to empty (whitespace-only), while `ContractListApi`, `QuestAcceptApi`, and most other player routes return **404** for `trimmedId.Length == 0`. Recommend matching the **404** position-gate pattern for consistency. +1. ~~**Align empty path `{id}` on POST with sibling player APIs** — `ContractIssueApi` returns **400** when the path id normalizes to empty (whitespace-only), while `ContractListApi`, `QuestAcceptApi`, and most other player routes return **404** for `trimmedId.Length == 0`. Recommend matching the **404** position-gate pattern for consistency.~~ **Done.** — position gate runs first; whitespace-only path returns **404**. -2. **Add POST 400 tests for missing seed fields** — Handler rejects empty **`templateId`** / **`seedBucket`** (lines 32–36 in `ContractIssueApi.cs`), but `ContractApiTests` only covers schema version and **`playerId`** mismatch. One test each (or a `[Theory]`) would lock the validation gate. +2. ~~**Add POST 400 tests for missing seed fields** — Handler rejects empty **`templateId`** / **`seedBucket`** (lines 32–36 in `ContractIssueApi.cs`), but `ContractApiTests` only covers schema version and **`playerId`** mismatch. One test each (or a `[Theory]`) would lock the validation gate.~~ **Done.** — `[Theory]` for empty/whitespace **`templateId`** and **`seedBucket`**; whitespace path **404** test added. -3. **Optional Postgres `ListForPlayer` round-trip** — Plan marks this optional; if CI Postgres is stable, a `[RequirePostgresFact]` mirroring NEO-146 store tests would guard SQL ordering/cap behavior separately from in-memory parity. +3. ~~**Optional Postgres `ListForPlayer` round-trip** — Plan marks this optional; if CI Postgres is stable, a `[RequirePostgresFact]` mirroring NEO-146 store tests would guard SQL ordering/cap behavior separately from in-memory parity.~~ **Done.** — `ListForPlayer_ShouldPrependActiveAndCapCompletedRows_OnPostgres` in `ContractInstancePersistenceIntegrationTests`. ## Nits -- Nit: **`PostgresContractInstanceStore.ListForPlayer`** opens a connection then calls **`TryGetActiveForPlayer`**, which opens a second connection. Fine at prototype volume; could inline the active SELECT on the existing connection later. -- Nit: **`GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear`** invokes **`TryCompleteOnEncounterClear`** under **`// Act`** before the GET — consider moving completion into **`// Arrange`** so Act is HTTP-only (AAA clarity). -- Nit: Plan §1 Postgres snippet mentions **`completed_at DESC`** but kickoff + README + implementation use **`issuedAt` desc** — update the plan technical section on merge to remove the stale SQL note (implementation is correct). +- ~~Nit: **`PostgresContractInstanceStore.ListForPlayer`** opens a connection then calls **`TryGetActiveForPlayer`**, which opens a second connection. Fine at prototype volume; could inline the active SELECT on the existing connection later.~~ **Done.** — active SELECT inlined on the list connection. +- ~~Nit: **`GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear`** invokes **`TryCompleteOnEncounterClear`** under **`// Act`** before the GET — consider moving completion into **`// Arrange`** so Act is HTTP-only (AAA clarity).~~ **Done.** +- ~~Nit: Plan §1 Postgres snippet mentions **`completed_at DESC`** but kickoff + README + implementation use **`issuedAt` desc** — update the plan technical section on merge to remove the stale SQL note (implementation is correct).~~ **Done.** ## Verification diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs index d0beca4..63c2993 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs @@ -34,6 +34,51 @@ public sealed class ContractApiTests Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + [Fact] + public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + "/game/players/%20/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("", SeedBucket)] + [InlineData(" ", SeedBucket)] + [InlineData(TemplateId, "")] + [InlineData(TemplateId, " ")] + public async Task PostContractIssue_ShouldReturnBadRequest_WhenRequiredSeedFieldsEmpty( + string templateId, + string seedBucket) + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create( + new ContractIssueRequest + { + SchemaVersion = ContractIssueRequest.CurrentSchemaVersion, + PlayerId = PlayerId, + TemplateId = templateId, + SeedBucket = seedBucket, + })); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Fact] public async Task PostContractIssue_ShouldReturnBadRequest_WhenPlayerIdMismatch() { @@ -211,8 +256,6 @@ public sealed class ContractApiTests var issueBody = await issue.Content.ReadFromJsonAsync(); Assert.NotNull(issueBody?.Contract); var instanceId = issueBody!.Contract!.ContractInstanceId; - - // Act var completed = ContractCompletionOperations.TryCompleteOnEncounterClear( PlayerId, EncounterId, @@ -230,10 +273,12 @@ public sealed class ContractApiTests deps.StandingStore, deps.AuditStore, deps.TimeProvider); + Assert.True(completed.Success); + + // Act var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); // Assert - Assert.True(completed.Success); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs index 008f1a0..551c721 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs @@ -113,6 +113,55 @@ public sealed class ContractInstancePersistenceIntegrationTests(PostgresIntegrat Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId); } + [RequirePostgresFact] + public async Task ListForPlayer_ShouldPrependActiveAndCapCompletedRows_OnPostgres() + { + // Arrange + await ResetContractInstanceTableAsync(); + using var scope = Factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + for (var i = 0; i < 12; i++) + { + var instanceId = $"prototype_contract_instance_pg_done_{i:D2}"; + Assert.True(store.TryCreateActive( + PlayerId, + instanceId, + TemplateId, + SeedBucket, + IssuedAt.AddHours(i), + out _)); + Assert.True(store.TryMarkComplete( + PlayerId, + instanceId, + CompletedAt.AddHours(i), + out _)); + } + + const string activeId = "prototype_contract_instance_pg_active"; + Assert.True(store.TryCreateActive( + PlayerId, + activeId, + TemplateId, + SeedBucket, + IssuedAt.AddDays(3), + out _)); + + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + + // Assert + Assert.Equal(11, rows.Count); + Assert.Equal(activeId, rows[0].ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, rows[0].Status); + Assert.All(rows.Skip(1), row => Assert.Equal(ContractInstanceStatus.Completed, row.Status)); + Assert.DoesNotContain( + rows.Skip(1), + row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_pg_done_00", StringComparison.Ordinal)); + Assert.DoesNotContain( + rows.Skip(1), + row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_pg_done_01", StringComparison.Ordinal)); + } + [RequirePostgresFact] public async Task CreateComplete_ShouldPersistAcrossNewFactory() { diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs b/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs index 4877e38..502e6b3 100644 --- a/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs +++ b/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs @@ -21,6 +21,12 @@ public static class ContractIssueApi IFactionStandingStore standingStore, IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + if (body is null || body.SchemaVersion != ContractIssueRequest.CurrentSchemaVersion) { return Results.BadRequest(); @@ -36,19 +42,12 @@ public static class ContractIssueApi return Results.BadRequest(); } - var trimmedId = id.Trim(); var normalizedPathPlayerId = ContractInstanceIds.NormalizePlayerId(trimmedId); - if (normalizedPathPlayerId.Length == 0 || - !string.Equals(normalizedBodyPlayerId, normalizedPathPlayerId, StringComparison.Ordinal)) + if (!string.Equals(normalizedBodyPlayerId, normalizedPathPlayerId, StringComparison.Ordinal)) { return Results.BadRequest(); } - if (!positions.TryGetPosition(trimmedId, out _)) - { - return Results.NotFound(); - } - var result = ContractGeneratorOperations.TryIssue( trimmedId, normalizedTemplateId, diff --git a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs index d4f481e..56ce4ae 100644 --- a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs @@ -331,9 +331,25 @@ public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSo } ContractInstanceState? active = null; - if (TryGetActiveForPlayer(player, out var activeSnapshot)) + using (var activeCmd = new Npgsql.NpgsqlCommand( + """ + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM contract_instance + WHERE player_id = @pid AND status = 'active' + LIMIT 1; + """, + conn)) { - active = activeSnapshot; + activeCmd.Parameters.AddWithValue("pid", player); + using var reader = activeCmd.ExecuteReader(); + if (reader.Read()) + { + var snapshot = ReadSnapshot(reader); + if (snapshot.Status == ContractInstanceStatus.Active) + { + active = snapshot; + } + } } var completed = new List(maxCompletedRows); From 5f2ab34fa29ffd56921172fb40ad1e4f2685420a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 11:07:47 -0400 Subject: [PATCH 5/7] NEO-151: Loop contract reset helper until capped GET is empty Bugbot: Bruno pre-request cleanup now clears all contract instances, not just the first active + 10 completed page from GET /contracts. --- .../reset-contract-instances-helper.js | 45 +++++++++++-------- docs/reviews/2026-06-28-NEO-151.md | 6 +++ 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js b/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js index 18cda66..e3b68ee 100644 --- a/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js +++ b/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js @@ -4,26 +4,35 @@ async function resetAllContractInstances(bru) { const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const jsonHeaders = { headers: { "Content-Type": "application/json" } }; - const listResponse = await axios.get( - `${baseUrl}/game/players/${playerId}/contracts`, - jsonHeaders, - ); - if (listResponse.status !== 200) { - throw new Error(`contracts GET failed: ${listResponse.status}`); + // GET is capped (active + 10 completed); loop until no rows remain so older + // completed instances outside the first page are cleared too. + const maxPasses = 100; + for (let pass = 0; pass < maxPasses; pass += 1) { + const listResponse = await axios.get( + `${baseUrl}/game/players/${playerId}/contracts`, + jsonHeaders, + ); + if (listResponse.status !== 200) { + throw new Error(`contracts GET failed: ${listResponse.status}`); + } + + const ids = (listResponse.data.contracts || []).map((row) => row.contractInstanceId); + if (ids.length === 0) { + return; + } + + await axios.post( + `${baseUrl}/game/players/${playerId}/__dev/quest-fixture`, + { + schemaVersion: 1, + resetContractInstanceIds: ids, + }, + jsonHeaders, + ); } - const ids = (listResponse.data.contracts || []).map((row) => row.contractInstanceId); - if (ids.length === 0) { - return; - } - - await axios.post( - `${baseUrl}/game/players/${playerId}/__dev/quest-fixture`, - { - schemaVersion: 1, - resetContractInstanceIds: ids, - }, - jsonHeaders, + throw new Error( + `resetAllContractInstances exceeded ${maxPasses} passes; contract list may still be non-empty`, ); } diff --git a/docs/reviews/2026-06-28-NEO-151.md b/docs/reviews/2026-06-28-NEO-151.md index 74fed77..244a192 100644 --- a/docs/reviews/2026-06-28-NEO-151.md +++ b/docs/reviews/2026-06-28-NEO-151.md @@ -47,6 +47,12 @@ None. - ~~Nit: **`GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear`** invokes **`TryCompleteOnEncounterClear`** under **`// Act`** before the GET — consider moving completion into **`// Arrange`** so Act is HTTP-only (AAA clarity).~~ **Done.** - ~~Nit: Plan §1 Postgres snippet mentions **`completed_at DESC`** but kickoff + README + implementation use **`issuedAt` desc** — update the plan technical section on merge to remove the stale SQL note (implementation is correct).~~ **Done.** +## Bugbot (PR #193) + +| Severity | Location | Finding | Status | +|----------|----------|---------|--------| +| Low | `reset-contract-instances-helper.js:27` | Reset helper only cleared one capped GET page (active + 10 completed); older rows could survive pre-request cleanup | **Done.** — loop GET → reset until list empty (max 100 passes) | + ## Verification ```bash From 50df06a3f9725516cb389a49655b53fa89a51e16 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 11:16:13 -0400 Subject: [PATCH 6/7] NEO-151: Fix Bruno helper path for collection-root scripts resolution Move reset-contract-instances-helper.js to neon-sprawl-server/scripts/ and fix encounter-clear bru requires so CI can resolve modules. --- .../contracts/Get contracts after encounter clear.bru | 6 +++--- .../neon-sprawl-server/contracts/Get contracts default.bru | 2 +- .../contracts/Issue prototype contract.bru | 2 +- .../scripts/reset-contract-instances-helper.js | 0 docs/reviews/2026-06-28-NEO-151.md | 3 ++- server/README.md | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) rename bruno/neon-sprawl-server/{contracts => }/scripts/reset-contract-instances-helper.js (100%) diff --git a/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru b/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru index ca8dc11..077f022 100644 --- a/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru +++ b/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru @@ -10,14 +10,14 @@ docs { script:pre-request { const axios = require("axios"); - const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper"); - const { resetPrototypeCombatTargets } = require("../encounter-progress/scripts/combat-targets-reset-helper.js"); + const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper.js"); + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); const { moveNearPrototypeNpc, PULSE_COOLDOWN_MS, MAX_PULSE_DEFEAT_ATTEMPTS, sleep, - } = require("../encounter-progress/scripts/prototype-npc-bruno-helper.js"); + } = require("./scripts/prototype-npc-bruno-helper.js"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const jsonHeaders = { headers: { "Content-Type": "application/json" } }; diff --git a/bruno/neon-sprawl-server/contracts/Get contracts default.bru b/bruno/neon-sprawl-server/contracts/Get contracts default.bru index 8e9617f..64cb476 100644 --- a/bruno/neon-sprawl-server/contracts/Get contracts default.bru +++ b/bruno/neon-sprawl-server/contracts/Get contracts default.bru @@ -10,7 +10,7 @@ docs { } script:pre-request { - const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper"); + const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper.js"); await resetAllContractInstances(bru); } diff --git a/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru b/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru index bef7147..59e7672 100644 --- a/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru +++ b/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru @@ -9,7 +9,7 @@ docs { } script:pre-request { - const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper"); + const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper.js"); await resetAllContractInstances(bru); } diff --git a/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js b/bruno/neon-sprawl-server/scripts/reset-contract-instances-helper.js similarity index 100% rename from bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js rename to bruno/neon-sprawl-server/scripts/reset-contract-instances-helper.js diff --git a/docs/reviews/2026-06-28-NEO-151.md b/docs/reviews/2026-06-28-NEO-151.md index 244a192..5b256bf 100644 --- a/docs/reviews/2026-06-28-NEO-151.md +++ b/docs/reviews/2026-06-28-NEO-151.md @@ -51,7 +51,8 @@ None. | Severity | Location | Finding | Status | |----------|----------|---------|--------| -| Low | `reset-contract-instances-helper.js:27` | Reset helper only cleared one capped GET page (active + 10 completed); older rows could survive pre-request cleanup | **Done.** — loop GET → reset until list empty (max 100 passes) | +| Low | `scripts/reset-contract-instances-helper.js` | Reset helper only cleared one capped GET page (active + 10 completed); older rows could survive pre-request cleanup | **Done.** — loop GET → reset until list empty (max 100 passes) | +| Low | `contracts/scripts/` helper path | Bruno resolves `./scripts/` from collection root, not per-folder — CI module not found | **Done.** — moved helper to `neon-sprawl-server/scripts/`; `.js` suffix on requires | ## Verification diff --git a/server/README.md b/server/README.md index ae6b3a4..b91f723 100644 --- a/server/README.md +++ b/server/README.md @@ -152,7 +152,7 @@ Plan: [NEO-141 implementation plan](../../docs/plans/NEO-141-implementation-plan **400** for bad schema; **404** when disabled, player unknown, unknown quest/faction id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` uses **`resetQuestIds`**, **`resetFactionIds`**, and **`completedQuestIds`**; order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**. -**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**. +**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). **`scripts/reset-contract-instances-helper.js`** clears all contract instances via capped GET loop + quest-fixture reset (NEO-151). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**. ## Resource-node catalog (`content/resource-nodes`, NEO-58) From d6ebc767e63e4870159433b52ddb6a8cfa20594c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 11:17:21 -0400 Subject: [PATCH 7/7] NEO-151: Fix Postgres ListForPlayer read race with single CTE query Bugbot: active and completed rows now load in one statement snapshot so GET /contracts cannot return the same instance twice after completion. --- docs/reviews/2026-06-28-NEO-151.md | 1 + .../PostgresContractInstanceStore.cs | 71 ++++++++----------- 2 files changed, 29 insertions(+), 43 deletions(-) diff --git a/docs/reviews/2026-06-28-NEO-151.md b/docs/reviews/2026-06-28-NEO-151.md index 5b256bf..7e4895e 100644 --- a/docs/reviews/2026-06-28-NEO-151.md +++ b/docs/reviews/2026-06-28-NEO-151.md @@ -53,6 +53,7 @@ None. |----------|----------|---------|--------| | Low | `scripts/reset-contract-instances-helper.js` | Reset helper only cleared one capped GET page (active + 10 completed); older rows could survive pre-request cleanup | **Done.** — loop GET → reset until list empty (max 100 passes) | | Low | `contracts/scripts/` helper path | Bruno resolves `./scripts/` from collection root, not per-folder — CI module not found | **Done.** — moved helper to `neon-sprawl-server/scripts/`; `.js` suffix on requires | +| Medium | `PostgresContractInstanceStore.ListForPlayer` | Active + completed read in two queries without snapshot — duplicate row if status flips mid-read | **Done.** — single CTE query for atomic list snapshot | ## Verification diff --git a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs index 56ce4ae..87b7890 100644 --- a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs @@ -330,56 +330,41 @@ public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSo return []; } - ContractInstanceState? active = null; - using (var activeCmd = new Npgsql.NpgsqlCommand( - """ - SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at - FROM contract_instance - WHERE player_id = @pid AND status = 'active' - LIMIT 1; - """, - conn)) - { - activeCmd.Parameters.AddWithValue("pid", player); - using var reader = activeCmd.ExecuteReader(); - if (reader.Read()) - { - var snapshot = ReadSnapshot(reader); - if (snapshot.Status == ContractInstanceStatus.Active) - { - active = snapshot; - } - } - } - - var completed = new List(maxCompletedRows); - if (maxCompletedRows > 0) - { - using var cmd = new Npgsql.NpgsqlCommand( - """ - SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + // Single statement snapshot — avoids active+completed duplicate rows if status flips mid-read. + using var cmd = new Npgsql.NpgsqlCommand( + """ + WITH active_row AS ( + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 0 AS section + FROM contract_instance + WHERE player_id = @pid AND status = 'active' + LIMIT 1 + ), + completed_rows AS ( + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 1 AS section FROM contract_instance WHERE player_id = @pid AND status = 'completed' ORDER BY issued_at DESC, contract_instance_id ASC - LIMIT @limit; - """, - conn); - cmd.Parameters.AddWithValue("pid", player); - cmd.Parameters.AddWithValue("limit", maxCompletedRows); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - { - completed.Add(ReadSnapshot(reader)); - } - } + LIMIT @limit + ) + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM ( + SELECT * FROM active_row + UNION ALL + SELECT * FROM completed_rows + ) AS listed + ORDER BY section, issued_at DESC, contract_instance_id ASC; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + cmd.Parameters.AddWithValue("limit", maxCompletedRows); - if (active is null) + var result = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) { - return completed; + result.Add(ReadSnapshot(reader)); } - var result = new List(1 + completed.Count) { active }; - result.AddRange(completed); return result; }