Merge pull request #193 from ViPro-Technologies/NEO-151-e7m4-08-contract-issue-post-per-player-contract-get

NEO-151: Contract issue POST + per-player contract GET
main
VinPropane 2026-06-28 11:24:44 -04:00 committed by GitHub
commit 060040e87a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1607 additions and 8 deletions

View File

@ -21,7 +21,7 @@ body:json {
docs { docs {
NEO-149: quest-fixture accepts resetContractInstanceIds — clears contract instance, contract_completion delivery row, and outcome audit for each id (idempotent when absent). 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 { tests {

View File

@ -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.js");
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
const {
moveNearPrototypeNpc,
PULSE_COOLDOWN_MS,
MAX_PULSE_DEFEAT_ATTEMPTS,
sleep,
} = 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" } };
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);
});
}

View File

@ -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.js");
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([]);
});
}

View File

@ -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.js");
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);
});
}

View File

@ -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);
});
}

View File

@ -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.
}

View File

@ -0,0 +1,39 @@
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" } };
// 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,
);
}
throw new Error(
`resetAllContractInstances exceeded ${maxPasses} passes; contract list may still be non-empty`,
);
}
module.exports = { resetAllContractInstances };

View File

@ -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 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 ## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,205 @@
# 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 (01) + up to 10 recent completed**, ordered by **`issuedAt` desc** | **Adopted** — user chose cap 10 |
| POST body shape | Full `contract-seed` vs path-only player id? | **Full seed fields + `schemaVersion` 1**; **`playerId` in body must match path `{id}`** (400 on mismatch); **`templateId` required** per schema + NEO-147 | **Adopted** — schema + NEO-147 precedent |
| Issue response envelope | Raw snapshot vs structured response? | **`{ issued, reasonCode, contract }`** mirroring quest accept **`{ accepted, reasonCode, quest }`** | **Adopted** — NEO-120 pattern |
| Grant summary DTOs | New contract types vs reuse quest summary? | **Reuse `QuestCompletionRewardSummaryJson`** and grant line types — same `IRewardDeliveryStore` snapshot shape | **Adopted** — NEO-129/NEO-140 |
| Store read for GET | Extend `IContractInstanceStore` vs ad-hoc SQL in API? | **Add `ListForPlayer(playerId, maxCompletedRows)`** on interface + both store impls | **Adopted** — store boundary precedent (NEO-146) |
| Issue deny HTTP status | 400 vs 200 with `issued: false`? | **200 OK** with **`issued: false`** + **`reasonCode`** (and optional **`contract`** snapshot on `active_contract_exists`) | **Adopted** — quest accept precedent |
| Bruno scope | Minimal vs full issue → clear → GET loop? | **`bruno/neon-sprawl-server/contracts/`** — issue, GET default, GET after encounter clear (reuse ability-cast / encounter clear spine) | **Adopted** — backlog in-scope |
| Telemetry / Godot | In scope? | **Out of scope** — NEO-152 comment hooks; NEO-153 client HUD | **Adopted** — backlog |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`POST /game/players/{id}/contracts/issue`** — `ContractIssueApi` + request/response DTOs; delegates to **`ContractGeneratorOperations.TryIssue`** with full DI registries (encounter, item, skill, faction per NEO-150 handoff).
- **`GET /game/players/{id}/contracts`** — `ContractListApi` + list DTOs; active row + up to **10** recent completed instances with template summary, **`encounterTemplateId`**, **`completionRewardSummary`** when delivered.
- **`IContractInstanceStore.ListForPlayer`** — read path for completed history (in-memory + Postgres).
- Integration tests (`ContractIssueApiTests` / `ContractListApiTests` or combined `ContractApiTests`).
- Bruno **`bruno/neon-sprawl-server/contracts/`** — issue → encounter clear → GET loop; update contract-stores reset bru cross-link.
- `server/README.md` contract HTTP sections; E7.M4 module anchor.
**Out of scope (from Linear + backlog):**
- Godot parse/HUD (**NEO-153** / E7M4-10).
- Telemetry comment hooks (**NEO-152** / E7M4-09).
- Optional **`templateId`** auto-select over HTTP (ops layer supports null; HTTP requires explicit id per schema).
- Live E4.M1 zone read for **`zoneDifficultyBand`** (POST override only until E4.M1).
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — server HTTP must land in this story first.
## Acceptance criteria checklist
- [x] POST issue returns **`active`** instance JSON.
- [x] GET lists active contract with encounter objective id.
- [x] GET completed row includes delivery summary after encounter clear.
## Implementation reconciliation (shipped)
- **`IContractInstanceStore.ListForPlayer`** — active first + up to 10 completed rows (`issuedAt` desc); in-memory + Postgres impls.
- **`ContractIssueApi`** — `POST …/contracts/issue` delegates to **`ContractGeneratorOperations.TryIssue`** with full registry DI.
- **`ContractListApi`** — `GET …/contracts` with **`completionRewardSummary`** via shared **`QuestProgressApi`** delivery mapper.
- **Tests:** 5 store list unit tests + 14 HTTP integration tests (incl. empty seed-field `[Theory]`) + Postgres **`ListForPlayer`** persistence test; **933** tests green.
- **Bruno:** `bruno/neon-sprawl-server/contracts/` — default GET, issue, encounter-clear capstone.
- **Docs:** `server/README.md`, `E7_M4_ContractMissionGenerator.md`, alignment register.
## Technical approach
### 1. Store read — `IContractInstanceStore.ListForPlayer`
```csharp
IReadOnlyList<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows);
```
**Semantics:**
- Normalize **`playerId`**; return empty list when player not writable / unknown to store.
- If an **active** row exists, include it first (at most one).
- Append up to **`maxCompletedRows`** **completed** instances for that player, **`issuedAt` descending** (tie-break **`contractInstanceId`** ordinal).
- Prototype constant **`ContractListApi.MaxCompletedRows = 10`** (kickoff decision).
**Postgres:** `SELECT … WHERE player_id = @pid AND status = 'completed' ORDER BY issued_at DESC, contract_instance_id ASC LIMIT @n` plus active query on the same connection.
**In-memory:** scan **`byInstanceId`** for player-owned completed rows, sort, take N, prepend active.
Unit tests in **`InMemoryContractInstanceStoreTests`**: empty player, active only, completed only, active + multiple completed ordering, cap respected.
### 2. DTOs (`Game/Contracts/ContractApiDtos.cs`)
| Type | Role |
|------|------|
| **`ContractIssueRequest`** | `schemaVersion` **1**, `playerId`, `templateId`, `seedBucket`, optional `zoneDifficultyBand` — mirrors [`contract-seed.schema.json`](../../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. **404** when path `{id}` is whitespace-only or player unknown (**`IPositionStateStore`** gate — same as quest progress / accept).
2. **400** when body null, **`schemaVersion`** mismatch, empty **`templateId`** / **`seedBucket`** / body **`playerId`**, or body **`playerId`** ≠ trimmed path `{id}` after normalize.
3. Call **`ContractGeneratorOperations.TryIssue`** with path player id, body fields, and injected registries/stores (**mirror `ContractGeneratorOperationsIntegrationTests`** DI wiring).
4. **200** **`ContractIssueResponse`** via **`MapIssueResponse`** — on success map **`ContractInstanceRowJson`** from result snapshot + template registry; on deny include **`reasonCode`** and optional snapshot row (e.g. **`active_contract_exists`**).
Register in **`Program.cs`** alongside quest routes: **`app.MapContractIssueApi()`**.
### 4. `ContractListApi`
**Route:** `GET /game/players/{id}/contracts`
**Handler flow:**
1. **404** unknown player (position gate).
2. **`instanceStore.ListForPlayer(trimmedId, ContractListApi.MaxCompletedRows)`**.
3. Map each snapshot to **`ContractInstanceRowJson`**.
4. **200** **`ContractListResponse`** — **`contracts`** may be empty array.
### 5. Integration tests (`ContractApiTests`)
| Test | Covers |
|------|--------|
| POST issue 404 unknown player | Position gate |
| POST issue 400 schema / playerId mismatch | Request validation |
| POST issue 200 happy path | **`issued: true`**, **`active`**, **`encounterTemplateId: prototype_combat_pocket`**, deterministic **`contractInstanceId`** |
| POST issue 200 deny second active | **`issued: false`**, **`active_contract_exists`**, optional active row echo |
| GET 404 unknown player | Position gate |
| GET empty list | Known player, no instances |
| GET active after issue | Active row with encounter id, no summary |
| GET completed after encounter clear | Issue → **`ContractCompletionOperations.TryCompleteOnEncounterClear`** (or ability-cast integration path) → GET asserts **`completed`**, **`completionRewardSummary`** with prototype bundle lines |
| GET idempotent replay | Second GET same summary (mirror NEO-129) |
Optional **`RequirePostgresFact`**: issue + list round-trip on Postgres store (mirror NEO-146 persistence tests).
### 6. Bruno (`bruno/neon-sprawl-server/contracts/`)
| File | Purpose |
|------|---------|
| `Issue prototype contract.bru` | POST issue with frozen template + seed bucket |
| `Get contracts default.bru` | GET empty or active-only baseline |
| `Get contracts after encounter clear.bru` | Full spine: issue → clear **`prototype_combat_pocket`** → GET asserts **`completionRewardSummary`** |
Update **`contract-stores/Reset contract instance via quest fixture.bru`** docs to reference real instance id from issue bru.
### 7. Docs
- **`server/README.md`** — contract issue GET/POST routes, JSON samples, reason codes (link generator/completion sections).
- **`E7_M4_ContractMissionGenerator.md`** — E7M4-08 HTTP landed note.
- **`documentation_and_implementation_alignment.md`** — E7.M4 row after implementation.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-151-implementation-plan.md` | This plan |
| `server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs` | Issue/list request/response + row JSON types |
| `server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs` | `POST …/contracts/issue` handler + mapper |
| `server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs` | `GET …/contracts` handler + row mapper |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs` | AAA HTTP integration tests |
| `bruno/neon-sprawl-server/contracts/Issue prototype contract.bru` | Bruno issue spine |
| `bruno/neon-sprawl-server/contracts/Get contracts default.bru` | Bruno GET baseline |
| `bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru` | Bruno issue → clear → GET capstone |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs` | Add **`ListForPlayer`** for completed history reads |
| `server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs` | Implement **`ListForPlayer`** |
| `server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs` | Implement **`ListForPlayer`** SQL |
| `server/NeonSprawl.Server/Program.cs` | Register **`MapContractIssueApi`** + **`MapContractListApi`** |
| `server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs` | Unit tests for **`ListForPlayer`** ordering + cap |
| `server/README.md` | Document contract issue/list HTTP routes + samples |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-151 HTTP anchor |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M4 alignment note (post-implementation) |
| `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru` | Cross-link contracts bru folder |
## Tests
| File | What it covers |
|------|----------------|
| `InMemoryContractInstanceStoreTests` | **`ListForPlayer`**: empty, active-only, completed cap, ordering |
| `ContractApiTests` | POST issue happy/deny paths; GET empty/active/completed + **`completionRewardSummary`**; 404/400 gates |
| `ContractGeneratorOperationsIntegrationTests` (existing) | Unchanged — orchestrator DI baseline |
| CI | `dotnet test` green; Bruno manual against dev server |
Manual Godot QA: **none** (server HTTP; capstone **NEO-154**). Bruno: **`contracts/`** folder per plan.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **`TryIssue` registry wiring in HTTP handler** | Pass same four registries as **`ContractGeneratorOperationsIntegrationTests`** — verified at kickoff (NEO-150 handoff) | `adopted` |
| Duplicate grant-summary mapper vs quest API | **`QuestProgressApi.MapCompletionRewardSummary`** extended with `sourceKind` + `sourceId`; contract API reuses it | `adopted` |
| GET payload size with 10 completed rows | Accept for prototype; client (**NEO-153**) reads active row first | `adopted` |
| **`contract-seed.schema.json` lacks `schemaVersion`** | HTTP DTO adds **`schemaVersion`** alongside seed fields (quest/inventory precedent); schema file unchanged unless CI needs `$ref` later | `adopted` |
| Postgres **`ListForPlayer`** index | Existing **`player_id`** + status filter sufficient for prototype volume; no new migration | `adopted` |
## Client counterpart
[NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — **`contract_client.gd`**, issue binding, HUD labels; blocked until this story merges. Surfaces deny codes including **`economy_cap_exceeded`** and **`invalid_reward_bundle`** from NEO-150.

View File

@ -0,0 +1,68 @@
# 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.~~ **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 3236 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.~~ **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.~~ **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.**
## Bugbot (PR #193)
| Severity | Location | Finding | Status |
|----------|----------|---------|--------|
| 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
```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`**.

View File

@ -0,0 +1,406 @@
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_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()
{
// 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<ContractIssueResponse>();
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<ContractIssueResponse>();
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<ContractListResponse>();
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<ContractListResponse>();
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<ContractIssueResponse>();
Assert.NotNull(issueBody?.Contract);
var instanceId = issueBody!.Contract!.ContractInstanceId;
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);
Assert.True(completed.Success);
// Act
var response = await client.GetAsync($"/game/players/{PlayerId}/contracts");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<ContractListResponse>();
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<ContractListResponse>();
var secondBody = await second.Content.ReadFromJsonAsync<ContractListResponse>();
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<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
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);
}

View File

@ -407,5 +407,8 @@ public sealed class ContractGeneratorOperationsTests
public bool TryClearInstance(string playerId, string contractInstanceId) => public bool TryClearInstance(string playerId, string contractInstanceId) =>
inner.TryClearInstance(playerId, contractInstanceId); inner.TryClearInstance(playerId, contractInstanceId);
public IReadOnlyList<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows) =>
inner.ListForPlayer(playerId, maxCompletedRows);
} }
} }

View File

@ -113,6 +113,55 @@ public sealed class ContractInstancePersistenceIntegrationTests(PostgresIntegrat
Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId); 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<IContractInstanceStore>();
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] [RequirePostgresFact]
public async Task CreateComplete_ShouldPersistAcrossNewFactory() public async Task CreateComplete_ShouldPersistAcrossNewFactory()
{ {

View File

@ -312,6 +312,127 @@ public sealed class InMemoryContractInstanceStoreTests
second.Status == ContractInstanceStatus.Active); 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] [Fact]
public void TryClearInstance_ShouldReturnTrue_WhenRowMissing() public void TryClearInstance_ShouldReturnTrue_WhenRowMissing()
{ {

View File

@ -0,0 +1,95 @@
using System.Text.Json.Serialization;
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>POST body for <c>POST /game/players/{{id}}/contracts/issue</c> (NEO-151).</summary>
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; }
}
/// <summary>POST response for contract issue (NEO-151).</summary>
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; }
}
/// <summary>JSON body for <c>GET /game/players/{{id}}/contracts</c> (NEO-151).</summary>
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<ContractInstanceRowJson> Contracts { get; init; }
}
/// <summary>Authoritative contract instance projection for HTTP clients (NEO-151).</summary>
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; }
/// <summary><c>active</c> or <c>completed</c>.</summary>
[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; }
}

View File

@ -0,0 +1,96 @@
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;
/// <summary>Maps <c>POST /game/players/{{id}}/contracts/issue</c> (NEO-151).</summary>
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) =>
{
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();
}
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 normalizedPathPlayerId = ContractInstanceIds.NormalizePlayerId(trimmedId);
if (!string.Equals(normalizedBodyPlayerId, normalizedPathPlayerId, StringComparison.Ordinal))
{
return Results.BadRequest();
}
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,
};
}
}

View File

@ -0,0 +1,95 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Maps <c>GET /game/players/{{id}}/contracts</c> (NEO-151).</summary>
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<ContractInstanceRowJson>(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}'."),
};
}

View File

@ -36,4 +36,10 @@ public interface IContractInstanceStore
/// <summary>Dev fixture only — removes one instance row; idempotent when absent.</summary> /// <summary>Dev fixture only — removes one instance row; idempotent when absent.</summary>
bool TryClearInstance(string playerId, string contractInstanceId); bool TryClearInstance(string playerId, string contractInstanceId);
/// <summary>
/// Active row first (when present), then up to <paramref name="maxCompletedRows"/> completed rows
/// for the player ordered by <see cref="ContractInstanceState.IssuedAt"/> descending.
/// </summary>
IReadOnlyList<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows);
} }

View File

@ -240,6 +240,62 @@ public sealed class InMemoryContractInstanceStore(IOptions<GamePositionOptions>
} }
} }
/// <inheritdoc />
public IReadOnlyList<ContractInstanceState> 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<ContractInstanceState>();
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<ContractInstanceState>(1 + completed.Count) { active };
result.AddRange(completed);
return result;
}
}
/// <inheritdoc /> /// <inheritdoc />
public bool TryClearInstance(string playerId, string contractInstanceId) public bool TryClearInstance(string playerId, string contractInstanceId)
{ {

View File

@ -314,6 +314,60 @@ public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSo
return true; return true;
} }
/// <inheritdoc />
public IReadOnlyList<ContractInstanceState> 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 [];
}
// 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
)
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);
var result = new List<ContractInstanceState>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
result.Add(ReadSnapshot(reader));
}
return result;
}
/// <inheritdoc /> /// <inheritdoc />
public bool TryClearInstance(string playerId, string contractInstanceId) public bool TryClearInstance(string playerId, string contractInstanceId)
{ {

View File

@ -121,20 +121,34 @@ public static class QuestProgressApi
private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary( private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary(
string playerId, string playerId,
string questId, string questId,
IRewardDeliveryStore deliveryStore) =>
MapCompletionRewardSummary(
playerId,
RewardDeliverySourceKinds.QuestCompletion,
questId,
deliveryStore);
internal static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary(
string playerId,
string sourceKind,
string sourceId,
IRewardDeliveryStore deliveryStore) IRewardDeliveryStore deliveryStore)
{ {
if (!deliveryStore.TryGet(playerId, questId, out var deliveryEvent)) if (!deliveryStore.TryGet(playerId, sourceKind, sourceId, out var deliveryEvent))
{ {
return null; return null;
} }
return new QuestCompletionRewardSummaryJson return MapCompletionRewardSummary(deliveryEvent);
}
internal static QuestCompletionRewardSummaryJson MapCompletionRewardSummary(RewardDeliveryEvent deliveryEvent) =>
new()
{ {
ItemGrants = MapItemGrants(deliveryEvent.GrantedItems), ItemGrants = MapItemGrants(deliveryEvent.GrantedItems),
SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp), SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp),
ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation), ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation),
}; };
}
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedItems"/>.</summary> /// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedItems"/>.</summary>
private static List<QuestItemGrantJson> MapItemGrants(IReadOnlyList<RewardItemGrantApplied> grants) private static List<QuestItemGrantJson> MapItemGrants(IReadOnlyList<RewardItemGrantApplied> grants)

View File

@ -90,6 +90,8 @@ app.MapFactionStandingApi();
app.MapEncounterProgressApi(); app.MapEncounterProgressApi();
app.MapQuestProgressApi(); app.MapQuestProgressApi();
app.MapQuestAcceptApi(); app.MapQuestAcceptApi();
app.MapContractIssueApi();
app.MapContractListApi();
app.MapPerkStateApi(); app.MapPerkStateApi();
if (app.Environment.IsDevelopment() || if (app.Environment.IsDevelopment() ||
app.Environment.IsEnvironment("Testing") || app.Environment.IsEnvironment("Testing") ||

View File

@ -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`**. **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) ## Resource-node catalog (`content/resource-nodes`, NEO-58)
@ -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. - **`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`**. - **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**.
- **`TryClearInstance`** — dev fixture only; removes one instance row. - **`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). 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. **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) ## 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. **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) ## Contract outcome store (NEO-146)