Compare commits

...

13 Commits

Author SHA1 Message Date
VinPropane fe0d23b01c
Merge pull request #194 from ViPro-Technologies/NEO-152-e7m4-09-contract-telemetry-hook-sites-comment-only
NEO-152: Contract telemetry hook sites (comment-only)
2026-06-28 12:53:58 -04:00
VinPropane d96a83b6ef NEO-152: Address code review doc and alignment register nits.
Add NEO-152 plan link to alignment footer; reconcile plan with
method-level CompensatingRevertAll anchor; strike review suggestions.
2026-06-28 12:46:32 -04:00
VinPropane f665841ba2 NEO-152: Add code review for contract telemetry hook sites. 2026-06-28 12:39:25 -04:00
VinPropane 0d089a6dff NEO-152: Add comment-only contract telemetry hook sites.
Anchor contract_issued, contract_complete, and reward_anomaly
for E9.M1; document hooks in server README and module docs.
2026-06-28 12:08:05 -04:00
VinPropane 6e5ea78cc8 NEO-152: Add kickoff implementation plan for contract telemetry hooks.
Document comment-only E9.M1 anchor sites for contract_issued,
contract_complete, and reward_anomaly per E7M4-09 backlog scope.
2026-06-28 11:27:51 -04:00
VinPropane 060040e87a
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
2026-06-28 11:24:44 -04:00
VinPropane d6ebc767e6 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.
2026-06-28 11:17:21 -04:00
VinPropane 50df06a3f9 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.
2026-06-28 11:16:13 -04:00
VinPropane 5f2ab34fa2 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.
2026-06-28 11:07:47 -04:00
VinPropane 0888262f13 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.
2026-06-28 10:25:58 -04:00
VinPropane f6ef0a6227 NEO-151: Add code review for contract issue and list HTTP API 2026-06-28 10:23:31 -04:00
VinPropane c0252ee8be 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.
2026-06-28 10:21:10 -04:00
VinPropane 9994aee3cf 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.
2026-06-28 10:13:39 -04:00
30 changed files with 1894 additions and 13 deletions

View File

@ -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 {

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,10 @@ 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).
**Contract telemetry hooks (NEO-152):** comment-only **`contract_issued`**, **`contract_complete`**, and **`reward_anomaly`** anchor sites in generator/completion ops + shared reward router rollback; [server README — Contract telemetry hooks (NEO-152)](../../../server/README.md#contract-telemetry-hooks-neo-152); plan [NEO-152](../../plans/NEO-152-implementation-plan.md).
## Source anchors
- 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

@ -307,7 +307,9 @@ Working backlog for **Epic 7 — Slice 4** ([contract generator](../decompositio
**Acceptance criteria**
- [ ] Hook sites documented in README; no behavior change beyond comments.
- [x] Hook sites documented in README; no behavior change beyond comments.
**Landed (NEO-152):** comment-only **`contract_issued`**, **`contract_complete`**, and **`reward_anomaly`** hooks; [server README — Contract telemetry hooks (NEO-152)](../../server/README.md#contract-telemetry-hooks-neo-152); plan [NEO-152](../../plans/NEO-152-implementation-plan.md).
**Client counterpart:** none (infrastructure-only).

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,188 @@
# NEO-152 — E7M4-09: Contract telemetry hook sites (comment-only)
**Linear:** [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152)
**Branch:** `NEO-152-e7m4-09-contract-telemetry-hook-sites-comment-only`
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-09**
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
**Pattern:** [NEO-121](NEO-121-implementation-plan.md) / [NEO-130](NEO-130-implementation-plan.md) / [NEO-141](NEO-141-implementation-plan.md) — comment-only engine anchors + `TODO(E9.M1)`; engine-only (no duplicate API-layer hooks)
**Precursors:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) **`Done`** — `ContractGeneratorOperations.TryIssue`; [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) **`Done`** — `ContractCompletionOperations` + partial `contract_complete` hook comment; [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) **`Done`** — `ContractEconomyValidation` at issue (deferred `reward_anomaly` to this story); [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) **`Done`** — contract HTTP (**on `main`**)
**Blocks:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) (Godot contract HUD — telemetry vocabulary complete for server path)
**Client counterpart:** none (infrastructure-only per [E7M4-09](E7M4-pre-production-backlog.md#e7m4-09--contract-telemetry-hook-sites-comment-only))
## Goal
Anchor future E9.M1 catalog events **`contract_issued`**, **`contract_complete`**, and **`reward_anomaly`** on the server-authoritative contract path without ingest or behavior change.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M4-09 backlog scope](E7M4-pre-production-backlog.md#e7m4-09--contract-telemetry-hook-sites-comment-only), landed contract ops/HTTP (NEO-147/149/150/151), and NEO-121/130/141 comment-only telemetry precedents settle scope.
| Topic | Decision | Evidence |
|-------|----------|----------|
| **Runtime behavior** | Comments-only + `TODO(E9.M1)` — no ingest, no `ILogger` | NEO-121/130/141 precedent |
| **`contract_issued` anchor** | **`ContractGeneratorOperations.TryIssue`** — after successful **`TryCreateActive`** (first-time active row persisted) | Backlog E7M4-09; NEO-147 deferred hook |
| **`contract_complete` anchor** | **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — after successful **`TryMarkComplete`** + outcome append (delivery already succeeded) | Backlog E7M4-09; partial comment landed in NEO-149 — complete to NEO-121 block style |
| **`reward_anomaly` — economy deny** | **`ContractGeneratorOperations.TryIssue`** when **`ContractEconomyValidation.TryValidateTemplate`** returns false (`economy_cap_exceeded`, `invalid_reward_bundle`) | Backlog E7M4-09; NEO-150 deferred |
| **`reward_anomaly` — delivery rollback** | Private **`CompensatingRevertAll`** in **`RewardRouterOperations`** (single method-level stub — all **`TryDeliverBundle`** rollback call sites); E9.M1 filters by **`SourceKind`** | Shared router core (NEO-148); DRY vs per-call-site duplicates (NEO-121 central **`Deny`** precedent) |
| **API-layer hooks** | **None****`ContractIssueApi`** / **`ContractListApi`** delegate to ops only | NEO-121/141 engine-only precedent |
| **`reward_delivery` for contracts** | **No duplicate** — reuse NEO-130 **`reward_delivery`** hook at **`TryRecord`** success inside shared **`TryDeliverBundle`**; README cross-links contract **`SourceKind`** | NEO-130 already documents payload; contract uses same hook site |
| **Manual QA doc** | **None** — comment-only server story; `dotnet test` covers regression | NEO-141 precedent; no user-visible behavior |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- Comment-only **`contract_issued`** hook in **`ContractGeneratorOperations.TryIssue`** (successful issue commit).
- Complete comment-only **`contract_complete`** hook in **`ContractCompletionOperations`** (verify/extend NEO-149 partial block).
- Comment-only **`reward_anomaly`** stubs at economy validation deny (issue path) and delivery rollback paths (shared router bundle).
- **`server/README.md`** — dedicated **Contract telemetry hooks (NEO-152)** subsection with event table.
- **`E7_M4_ContractMissionGenerator.md`**, alignment register, E7M4 backlog updates when implementation completes.
**Out of scope:**
- E9.M1 ingest pipeline; runtime telemetry emit.
- `ILogger` / metrics / dev-only log lines.
- Duplicate hooks in **`ContractIssueApi`**, **`ContractListApi`**, **`EncounterCompletionOperations`**, or **`ContractEconomyValidation`** (orchestrator/router anchor only).
- Godot client (**NEO-153** / E7M4-10).
**Client counterpart:** none.
## Acceptance criteria checklist
- [x] Hook sites documented in README; no behavior change beyond comments.
## Implementation reconciliation (shipped)
- **`ContractGeneratorOperations.TryIssue`:** **`contract_issued`** hook after successful **`TryCreateActive`**; **`reward_anomaly`** stub on economy validation deny.
- **`ContractCompletionOperations.TryCompleteOnEncounterClear`:** **`contract_complete`** hook extended to NEO-121 block style (NEO-149 partial comment completed).
- **`RewardRouterOperations.TryDeliverBundle` / `CompensatingRevertAll`:** **`reward_anomaly`** stub for partial-apply rollback (single block on private **`CompensatingRevertAll`** — covers all four **`TryDeliverBundle`** call sites; DRY vs per-call-site duplicates); NEO-130 **`reward_delivery`** comment updated for contract **`SourceKind`**.
- **Docs:** `server/README.md` **Contract telemetry hooks (NEO-152)** subsection; E7.M4 module + alignment register + E7M4 backlog updated.
- **Tests:** no new tests — comments-only; **`dotnet test`** regression green.
## Technical approach
### 1. Shipped state (post-NEO-152)
| Location | Shipped state |
|----------|---------------|
| **`ContractGeneratorOperations.TryIssue`** | **`contract_issued`** + economy-deny **`reward_anomaly`** comment blocks |
| **`ContractCompletionOperations.TryCompleteOnEncounterClear`** | Full NEO-121-style **`contract_complete`** hook after deliver-then-mark |
| **`RewardRouterOperations.CompensatingRevertAll`** | Single **`reward_anomaly`** stub (private method — all rollback call sites) |
| **`server/README.md`** | **Contract telemetry hooks (NEO-152)** subsection + NEO-130 cross-link |
### 2. Hook site A — `contract_issued`
**Anchor:** **`ContractGeneratorOperations.TryIssue`**, immediately after **`instanceStore.TryCreateActive`** returns **`true`**.
- Comment block names future E9.M1 event **`contract_issued`**.
- **`TODO(E9.M1): catalog emit`** — once per first-time active instance persist.
- **Not** on: deny paths, idempotent create failure, or economy validation failure.
- Planned payload fields: `playerId`, `contractInstanceId`, `templateId`, `seedBucket`, `encounterTemplateId`, `issuedAt`.
### 3. Hook site B — `contract_complete`
**Anchor:** **`ContractCompletionOperations.TryCompleteOnEncounterClear`**, after successful **`TryMarkComplete`** + outcome append (delivery already committed via router).
- Extend existing NEO-149 partial comment to full block style.
- **`TODO(E9.M1): catalog emit`** — first-time completion only; idempotent replay / already-completed short-circuit excluded.
- **Not** on: no-op paths (`no_active_contract`, `encounter_mismatch`), delivery deny (contract stays active), or mark-complete failure after successful delivery.
- Planned payload fields: `playerId`, `contractInstanceId`, `templateId`, `encounterId`, `completedAt`, idempotencyKey (`{playerId}:contract_complete:{contractInstanceId}`).
### 4. Hook site C — `reward_anomaly` (stub)
**C1 — economy validation deny**
**Anchor:** **`ContractGeneratorOperations.TryIssue`**, when **`ContractEconomyValidation.TryValidateTemplate`** returns **`false`** (before persist).
- Stub comment names **`reward_anomaly`** with ops **`reasonCode`** (`economy_cap_exceeded`, `invalid_reward_bundle`).
- Defense-in-depth path — startup catalog load should prevent most cases; issue-time lint is explicit Slice 4 AC.
**C2 — delivery rollback**
**Anchor:** private **`CompensatingRevertAll`** in **`RewardRouterOperations`** (invoked from four **`TryDeliverBundle`** rollback paths after partial grant apply — skill XP / reputation / TryRecord race loser). Single method-level stub covers all call sites (NEO-121 central **`Deny`** precedent).
- Stub comment names **`reward_anomaly`**; note E9.M1 filters by **`DeliveryTarget.SourceKind`** — contract completions use **`contract_completion`**.
- **Not** on: early denies before any grant apply (e.g. **`inventory_full`** simulation fail), idempotent **`TryGet`** replay, or successful **`TryRecord`**.
### 5. README consolidation
Add **`### Contract telemetry hooks (NEO-152)`** after [Contract completion operations (NEO-149)](../../server/README.md#contract-completion-operations-neo-149):
| Event | Anchor | When |
|-------|--------|------|
| **`contract_issued`** | **`ContractGeneratorOperations.TryIssue`** | After successful **`TryCreateActive`** (active instance persisted). |
| **`contract_complete`** | **`ContractCompletionOperations.TryCompleteOnEncounterClear`** | After successful deliver-then-mark (instance marked complete + outcome appended). |
| **`reward_anomaly`** | **`ContractGeneratorOperations.TryIssue`** (economy deny); **`RewardRouterOperations.CompensatingRevertAll`** | Economy cap / bundle cross-ref deny at issue; compensating revert after partial delivery apply. |
| **`reward_delivery`** *(cross-link)* | **`RewardRouterOperations.TryDeliverBundle`** | NEO-130 — first-time **`TryRecord`** success; contract path uses **`SourceKind = contract_completion`**. |
### 6. Relationship to adjacent hooks
| Event | Layer | Story |
|-------|-------|-------|
| **`contract_issued`** | **`ContractGeneratorOperations`** | NEO-152 |
| **`contract_complete`** | **`ContractCompletionOperations`** | NEO-152 |
| **`reward_anomaly`** | Generator (economy deny) + router (rollback) | NEO-152 |
| **`reward_delivery`** | **`RewardRouterOperations`** | NEO-130 (shared; contract via NEO-148) |
| **`reputation_delta`** | **`ReputationOperations`** | NEO-141 (contract rep grants funnel here) |
| **`encounter_complete`** | Encounter ops | NEO-109 (encounter clear triggers contract completion wiring) |
### 7. Flow (comment anchors only)
```mermaid
sequenceDiagram
participant Issue as ContractGeneratorOperations.TryIssue
participant Econ as ContractEconomyValidation
participant Store as IContractInstanceStore
participant Enc as EncounterCompletionOperations
participant Complete as ContractCompletionOperations
participant Router as RewardRouterOperations
Issue->>Econ: TryValidateTemplate
alt economy deny
Econ-->>Issue: fail (hook: reward_anomaly)
else pass
Issue->>Store: TryCreateActive
Store-->>Issue: success (hook: contract_issued)
end
Enc->>Complete: TryCompleteOnEncounterClear
Complete->>Router: TryDeliverContractCompletion
alt partial apply then revert
Router-->>Complete: deny (hook: reward_anomaly in bundle)
else delivery ok
Router-->>Complete: success (hook: reward_delivery NEO-130)
Complete->>Store: TryMarkComplete
Complete-->>Complete: append outcome (hook: contract_complete)
end
```
## Files to add
None — comment-only changes to existing ops files and docs; no new source or test files.
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs` | Add `contract_issued` hook after successful create; `reward_anomaly` stub on economy validation deny |
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperations.cs` | Verify/extend `contract_complete` hook block to NEO-121 style |
| `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | Add `reward_anomaly` stub on private `CompensatingRevertAll` (covers all rollback call sites in `TryDeliverBundle`) |
| `server/README.md` | Add **Contract telemetry hooks (NEO-152)** subsection with event table + NEO-130 cross-link |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | Status line: E7M4-09 NEO-152 telemetry hooks landed |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M4 row: E7M4-09 hook sites |
| `docs/plans/E7M4-pre-production-backlog.md` | E7M4-09 checkboxes + landed note |
## Tests
| File | Coverage |
|------|----------|
| *(none added or changed)* | Comments-only change; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing contract generator/completion/API tests unchanged). |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| `reward_anomaly` in shared `TryDeliverBundle` affects quest path docs | **Single stub** at rollback sites with **`SourceKind`** filter note — avoid duplicate contract-only copies | `adopted` |
| Partial `contract_complete` comment from NEO-149 | **Extend in place** — do not relocate hook to router or encounter layer | `adopted` |
| Economy deny hook vs validator module | **Anchor at orchestrator** (`TryIssue`) when validation fails — not inside `ContractEconomyValidation` | `adopted` |
| Duplicate `reward_delivery` for contracts | **No** — NEO-130 hook at `TryRecord` already covers contract path via `SourceKind` | `adopted` |

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,58 @@
# Code review — NEO-152 contract telemetry hook sites (comment-only)
**Date:** 2026-06-28
**Scope:** Branch `NEO-152-e7m4-09-contract-telemetry-hook-sites-comment-only` — commits `6e5ea78`..`0d089a6` (2 commits)
**Base:** `main`
**Issue:** [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152) — E7M4-09 contract telemetry hook sites (comment-only)
**Follow-up:** Review suggestions addressed in docs-only follow-up commit.
## Verdict
**Approve with nits**
## Summary
This branch anchors future E9.M1 catalog events on the server-authoritative contract path with comment-only hook sites and no runtime behavior change. **`contract_issued`** and economy-deny **`reward_anomaly`** land in **`ContractGeneratorOperations.TryIssue`**; **`contract_complete`** extends the NEO-149 partial block to full NEO-121 style; shared-router **`reward_anomaly`** documents partial-apply rollback on **`CompensatingRevertAll`**, and the NEO-130 **`reward_delivery`** comment is updated for contract **`SourceKind`**.
Hook placement follows NEO-121/130/141 precedents (engine-only, **`TODO(E9.M1)`**, no API-layer duplicates). Docs (implementation plan, README subsection, E7.M4 module, alignment register, E7M4 backlog) are updated. Full suite passes (**933** tests). Risk is negligible — comments only; no wire or ingest change.
## Documentation checked
| Document | Result |
|----------|--------|
| `docs/plans/NEO-152-implementation-plan.md` | **Matches** — AC checklist complete; shipped reconciliation aligns with diff; kickoff decisions (engine-only, no duplicate `reward_delivery`, economy deny at orchestrator) reflected in code |
| `docs/plans/E7M4-pre-production-backlog.md` | **Matches** — E7M4-09 AC checked; landed note + README/plan links |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-152 telemetry hooks line added after NEO-151 HTTP |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row includes E7M4-09 / NEO-152 body text + explicit [NEO-152 plan](../../plans/NEO-152-implementation-plan.md) in trailing refs |
| `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 telemetry hooks (NEO-152)** subsection with event table + NEO-130 cross-link |
| 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. ~~**Add `NEO-152` plan link to alignment register footer** — The E7.M4 row body cites E7M4-09 / NEO-152, but the pipe-separated trailing refs list `[NEO-144]` … `[NEO-151]` then `label E7.M4 on NEO-144NEO-154` without `[NEO-152 plan]`. Add for parity with adjacent landed stories.~~ **Done.** Added `[NEO-152 plan]` to alignment register trailing refs.
2. ~~**Note method-level vs call-site rollback anchor in plan** — Plan §4 C2 and the files table say stub comments at **`CompensatingRevertAll` invocations** in **`TryDeliverBundle`**; implementation uses a **single block on `CompensatingRevertAll`** (four call sites). Functionally equivalent and DRY (similar to NEO-121 central **`Deny`**); optional one-line note in plan **Implementation reconciliation** that the adopted anchor is the private method.~~ **Done.** Plan reconciliation, §1 (shipped state), §4 C2, README table, and files table updated.
## Nits
- Nit: **`reward_anomaly`** at economy validation deny is semantically a lint/cap deny rather than a delivery rollback; naming matches backlog/NEO-150 deferral — no change needed, but E9.M1 wiring should treat **`reasonCode`** distinctly from router rollback anomalies.
- ~~Nit: Plan **§1 Current state** table still describes pre-ship “no hook” state; **Implementation reconciliation** is authoritative — optional cleanup of §1 on merge to past tense.~~ **Done.** §1 renamed to **Shipped state (post-NEO-152)** with past-tense rows.
## Verification
```bash
dotnet test NeonSprawl.sln
```
Manual (optional — comment-only):
- Open `ContractGeneratorOperations.cs`, `ContractCompletionOperations.cs`, and `RewardRouterOperations.cs`; confirm **`TODO(E9.M1)`** markers, planned payload fields, and no new log/ingest calls.
- Confirm **`ContractIssueApi`** / **`ContractListApi`** have no NEO-152 hooks (engine-only).

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) =>
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);
}
[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]
public async Task CreateComplete_ShouldPersistAcrossNewFactory()
{

View File

@ -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()
{

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

@ -101,7 +101,10 @@ public static class ContractCompletionOperations
completedAt);
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `contract_complete` ---
// TODO(E9.M1): catalog emit — first-time completion only; idempotent replay returns below without hook.
// Planned payload: playerId, contractInstanceId, templateId, encounterId, completedAt, idempotencyKey.
// Planned payload fields: playerId, contractInstanceId, templateId, encounterId, completedAt,
// idempotencyKey ({playerId}:contract_complete:{contractInstanceId}).
// No ingest or ILogger here (comments-only).
return Success(completedSnapshot, outcome);
}

View File

@ -67,6 +67,11 @@ public static class ContractGeneratorOperations
factionRegistry,
out var economyReason))
{
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `reward_anomaly` ---
// TODO(E9.M1): catalog emit — economy validation deny at issue (defense-in-depth; startup load should prevent most).
// Planned payload fields: playerId, templateId, reasonCode (economy_cap_exceeded | invalid_reward_bundle).
// No ingest or ILogger here (comments-only).
return Deny(economyReason!);
}
@ -87,6 +92,11 @@ public static class ContractGeneratorOperations
timeProvider.GetUtcNow(),
out var snapshot))
{
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `contract_issued` ---
// TODO(E9.M1): catalog emit — first-time active instance persist only (deny paths excluded).
// Planned payload fields: playerId, contractInstanceId, templateId, seedBucket, encounterTemplateId, issuedAt.
// No ingest or ILogger here (comments-only).
return Success(snapshot, template.EncounterTemplateId);
}

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>
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 />
public bool TryClearInstance(string playerId, string contractInstanceId)
{

View File

@ -314,6 +314,60 @@ public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSo
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 />
public bool TryClearInstance(string playerId, string contractInstanceId)
{

View File

@ -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),
};
}
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedItems"/>.</summary>
private static List<QuestItemGrantJson> MapItemGrants(IReadOnlyList<RewardItemGrantApplied> grants)

View File

@ -12,7 +12,8 @@ namespace NeonSprawl.Server.Game.Rewards;
/// Applies quest and contract <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127, NEO-148).
/// Quest-state wiring: <see cref="QuestStateOperations"/> (NEO-128).
/// Reputation grants: <see cref="ReputationOperations"/> (NEO-138).
/// E9.M1 telemetry hook sites: <see cref="TryDeliverQuestCompletion"/> (NEO-130).
/// E9.M1 telemetry hook sites: <see cref="TryDeliverQuestCompletion"/> (NEO-130);
/// shared bundle rollback <c>reward_anomaly</c> (NEO-152).
/// </summary>
public static class RewardRouterOperations
{
@ -335,9 +336,9 @@ public static class RewardRouterOperations
if (deliveryStore.TryRecord(deliveryEvent))
{
// --- Telemetry hook site (NEO-130): future E9.M1 catalog event `reward_delivery` ---
// TODO(E9.M1): catalog emit — once per first-time quest completion delivery (TryRecord success).
// TODO(E9.M1): catalog emit — once per first-time quest or contract completion delivery (TryRecord success).
// Not on TryGet idempotent replay, deny paths, or TryRecord race-loser rollback paths.
// Planned payload fields: playerId, questId, deliveredAt, grantedItems (itemId, quantity),
// Planned payload fields: playerId, sourceKind, sourceId, deliveredAt, grantedItems (itemId, quantity),
// grantedSkillXp (skillId, amount), grantedReputation (factionId, amount), idempotencyKey.
// No ingest or ILogger here (comments-only).
@ -371,6 +372,11 @@ public static class RewardRouterOperations
return Deny(RewardDeliveryReasonCodes.AlreadyDelivered);
}
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `reward_anomaly` (stub) ---
// TODO(E9.M1): catalog emit — partial grant apply then compensating revert (skill XP, reputation, TryRecord race).
// E9.M1 filters by DeliveryTarget.SourceKind (contract_completion vs quest_completion).
// Planned payload fields: playerId, sourceKind, sourceId, reasonCode, partialAppliedGrants summary.
// Not on early denies before any grant apply or idempotent TryGet replay. No ingest or ILogger (comments-only).
private static void CompensatingRevertAll(
string playerId,
DeliveryTarget target,

View File

@ -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") ||

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`**.
**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)
@ -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,20 @@ 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 telemetry hooks (NEO-152)
Comment-only hook sites for future E9.M1 catalog events ([Epic 7 Slice 4](../../docs/decomposition/modules/E7_M4_ContractMissionGenerator.md#related-implementation-slices)). **`TODO(E9.M1)`** — no production ingest. **`ContractIssueApi`** and **`ContractListApi`** delegate to ops layers only (no duplicate API-layer hooks). Contract completion payouts emit **`reward_delivery`** via shared **`RewardRouterOperations.TryDeliverBundle`** — see [Reward telemetry hooks (NEO-130)](#reward-telemetry-hooks-neo-130) cross-link (`SourceKind = contract_completion`).
| Event | Anchor | When |
|-------|--------|------|
| **`contract_issued`** | **`ContractGeneratorOperations.TryIssue`** | After successful **`TryCreateActive`** (active instance persisted). |
| **`contract_complete`** | **`ContractCompletionOperations.TryCompleteOnEncounterClear`** | After successful deliver-then-mark (instance marked complete + outcome appended). |
| **`reward_anomaly`** | **`ContractGeneratorOperations.TryIssue`** (economy deny); **`RewardRouterOperations.CompensatingRevertAll`** (via **`TryDeliverBundle`**) | Economy cap / bundle cross-ref deny at issue; compensating revert after partial delivery apply. |
| **`reward_delivery`** *(cross-link)* | **`RewardRouterOperations.TryDeliverBundle`** | NEO-130 — first-time **`TryRecord`** success; contract path uses **`SourceKind = contract_completion`**. |
Plan: [NEO-152 implementation plan](../../docs/plans/NEO-152-implementation-plan.md).
## Contract outcome store (NEO-146)