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.
pull/193/head
VinPropane 2026-06-28 10:21:10 -04:00
parent 9994aee3cf
commit c0252ee8be
21 changed files with 1213 additions and 11 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");
const { resetPrototypeCombatTargets } = require("../encounter-progress/scripts/combat-targets-reset-helper.js");
const {
moveNearPrototypeNpc,
PULSE_COOLDOWN_MS,
MAX_PULSE_DEFEAT_ATTEMPTS,
sleep,
} = require("../encounter-progress/scripts/prototype-npc-bruno-helper.js");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
const slotIndex = 3;
async function defeatNpc(targetId) {
await moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders);
await axios.post(
`${baseUrl}/game/players/${playerId}/target/select`,
{ schemaVersion: 1, targetId },
jsonHeaders,
);
await sleep(bru, PULSE_COOLDOWN_MS);
const castBody = {
schemaVersion: 1,
slotIndex,
abilityId: "prototype_pulse",
targetId,
};
for (let i = 0; i < MAX_PULSE_DEFEAT_ATTEMPTS; i++) {
const response = await axios.post(
`${baseUrl}/game/players/${playerId}/ability-cast`,
castBody,
jsonHeaders,
);
const body = response.data;
if (!body?.accepted || !body?.combatResolution) {
throw new Error(
`cast ${i + 1} vs ${targetId} failed (${body?.reasonCode ?? "no_reason"}): ${JSON.stringify(body)}`,
);
}
if (body.combatResolution.targetDefeated) {
return;
}
if (i < MAX_PULSE_DEFEAT_ATTEMPTS - 1) {
await sleep(bru, PULSE_COOLDOWN_MS);
}
}
throw new Error(
`did not defeat ${targetId} within ${MAX_PULSE_DEFEAT_ATTEMPTS} pulses`,
);
}
await resetAllContractInstances(bru);
await resetPrototypeCombatTargets(bru);
const issueResponse = await axios.post(
`${baseUrl}/game/players/${playerId}/contracts/issue`,
{
schemaVersion: 1,
playerId,
templateId: "prototype_contract_clear_combat_pocket",
seedBucket: "2026-06-28-neo151-bruno-clear",
},
jsonHeaders,
);
if (issueResponse.status !== 200 || !issueResponse.data?.issued) {
throw new Error(`contract issue failed: ${JSON.stringify(issueResponse.data)}`);
}
bru.setVar("neo151ContractInstanceId", issueResponse.data.contract.contractInstanceId);
await axios.post(
`${baseUrl}/game/players/${playerId}/hotbar-loadout`,
{
schemaVersion: 1,
slots: [{ slotIndex, abilityId: "prototype_pulse" }],
},
jsonHeaders,
);
await defeatNpc("prototype_npc_melee");
await defeatNpc("prototype_npc_ranged");
await defeatNpc("prototype_npc_elite");
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/contracts
body: none
auth: none
}
script:post-response {
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const second = await axios.get(`${baseUrl}/game/players/${playerId}/contracts`);
bru.setVar("neo151SecondContractsGetJson", JSON.stringify(second.data));
}
tests {
test("completed contract row includes delivery summary", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
const instanceId = bru.getVar("neo151ContractInstanceId");
const row = body.contracts.find((x) => x.contractInstanceId === instanceId);
expect(row).to.be.an("object");
expect(row.status).to.equal("completed");
expect(row.encounterTemplateId).to.equal("prototype_combat_pocket");
expect(row.completedAt).to.be.a("string");
expect(row.completionRewardSummary).to.eql({
itemGrants: [{ itemId: "scrap_metal_bulk", quantity: 5 }],
skillXpGrants: [{ skillId: "salvage", amount: 15 }],
reputationGrants: [],
});
});
test("second GET completionRewardSummary matches first GET", function () {
const first = res.getBody();
const second = JSON.parse(bru.getVar("neo151SecondContractsGetJson"));
const instanceId = bru.getVar("neo151ContractInstanceId");
const firstRow = first.contracts.find((x) => x.contractInstanceId === instanceId);
const secondRow = second.contracts.find((x) => x.contractInstanceId === instanceId);
expect(secondRow.completionRewardSummary).to.eql(firstRow.completionRewardSummary);
});
}

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");
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");
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,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,30 @@
async function resetAllContractInstances(bru) {
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
const listResponse = await axios.get(
`${baseUrl}/game/players/${playerId}/contracts`,
jsonHeaders,
);
if (listResponse.status !== 200) {
throw new Error(`contracts GET failed: ${listResponse.status}`);
}
const ids = (listResponse.data.contracts || []).map((row) => row.contractInstanceId);
if (ids.length === 0) {
return;
}
await axios.post(
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
{
schemaVersion: 1,
resetContractInstanceIds: ids,
},
jsonHeaders,
);
}
module.exports = { resetAllContractInstances };

View File

@ -67,6 +67,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**Contract completion wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — encounter clear completes matching active instance + outcome audit; [server README — Contract completion operations](../../../server/README.md#contract-completion-operations-neo-149).
**Contract HTTP (NEO-151):** **`POST …/contracts/issue`** + **`GET …/contracts`** — issue envelope + per-player list with **`encounterTemplateId`** and **`completionRewardSummary`** on completed rows; Bruno `bruno/neon-sprawl-server/contracts/`; [server README — Contract issue HTTP (NEO-151)](../../../server/README.md#contract-issue-http-neo-151), [Per-player contract list HTTP (NEO-151)](../../../server/README.md#per-player-contract-list-http-neo-151); plan [NEO-151](../../plans/NEO-151-implementation-plan.md).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.

File diff suppressed because one or more lines are too long

View File

@ -48,9 +48,18 @@ Client-readable contract issuance and state without local math — **`POST …/c
## Acceptance criteria checklist
- [ ] POST issue returns **`active`** instance JSON.
- [ ] GET lists active contract with encounter objective id.
- [ ] GET completed row includes delivery summary after encounter clear.
- [x] POST issue returns **`active`** instance JSON.
- [x] GET lists active contract with encounter objective id.
- [x] GET completed row includes delivery summary after encounter clear.
## Implementation reconciliation (shipped)
- **`IContractInstanceStore.ListForPlayer`** — active first + up to 10 completed rows (`issuedAt` desc); in-memory + Postgres impls.
- **`ContractIssueApi`** — `POST …/contracts/issue` delegates to **`ContractGeneratorOperations.TryIssue`** with full registry DI.
- **`ContractListApi`** — `GET …/contracts` with **`completionRewardSummary`** via shared **`QuestProgressApi`** delivery mapper.
- **Tests:** 5 store list unit tests + 10 HTTP integration tests; **927** tests green.
- **Bruno:** `bruno/neon-sprawl-server/contracts/` — default GET, issue, encounter-clear capstone.
- **Docs:** `server/README.md`, `E7_M4_ContractMissionGenerator.md`, alignment register.
## Technical approach
@ -186,7 +195,7 @@ Manual Godot QA: **none** (server HTTP; capstone **NEO-154**). Bruno: **`contrac
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **`TryIssue` registry wiring in HTTP handler** | Pass same four registries as **`ContractGeneratorOperationsIntegrationTests`** — verified at kickoff (NEO-150 handoff) | `adopted` |
| Duplicate grant-summary mapper vs quest API | Extract **`internal`** shared mapper on **`QuestProgressApi`** if clean; else parallel private helpers — keep grant order parity | `pending` |
| Duplicate grant-summary mapper vs quest API | **`QuestProgressApi.MapCompletionRewardSummary`** extended with `sourceKind` + `sourceId`; contract API reuses it | `adopted` |
| GET payload size with 10 completed rows | Accept for prototype; client (**NEO-153**) reads active row first | `adopted` |
| **`contract-seed.schema.json` lacks `schemaVersion`** | HTTP DTO adds **`schemaVersion`** alongside seed fields (quest/inventory precedent); schema file unchanged unless CI needs `$ref` later | `adopted` |
| Postgres **`ListForPlayer`** index | Existing **`player_id`** + status filter sufficient for prototype volume; no new migration | `adopted` |

View File

@ -0,0 +1,361 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractApiTests
{
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string SeedBucket = "2026-06-28-neo151";
private const string DisplayName = "Clear Combat Pocket (Repeat)";
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 28, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsync(
"/game/players/missing-player/contracts/issue",
JsonContent.Create(CreateIssueRequest("missing-player")));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostContractIssue_ShouldReturnBadRequest_WhenPlayerIdMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsync(
$"/game/players/{PlayerId}/contracts/issue",
JsonContent.Create(CreateIssueRequest("other-player")));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostContractIssue_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsync(
$"/game/players/{PlayerId}/contracts/issue",
JsonContent.Create(
new ContractIssueRequest
{
SchemaVersion = 99,
PlayerId = PlayerId,
TemplateId = TemplateId,
SeedBucket = SeedBucket,
}));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostContractIssue_ShouldReturnIssuedTrue_WhenPrototypeTemplateEligible()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
PlayerId,
TemplateId,
SeedBucket);
// Act
var response = await client.PostAsync(
$"/game/players/{PlayerId}/contracts/issue",
JsonContent.Create(CreateIssueRequest(PlayerId)));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<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;
// Act
var completed = ContractCompletionOperations.TryCompleteOnEncounterClear(
PlayerId,
EncounterId,
deps.InstanceStore,
deps.OutcomeStore,
deps.TemplateRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
var response = await client.GetAsync($"/game/players/{PlayerId}/contracts");
// Assert
Assert.True(completed.Success);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<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

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

@ -0,0 +1,97 @@
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts;
/// <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) =>
{
if (body is null || body.SchemaVersion != ContractIssueRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(body.TemplateId);
var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(body.SeedBucket);
var normalizedBodyPlayerId = ContractInstanceIds.NormalizePlayerId(body.PlayerId);
if (normalizedTemplateId.Length == 0 ||
normalizedSeedBucket.Length == 0 ||
normalizedBodyPlayerId.Length == 0)
{
return Results.BadRequest();
}
var trimmedId = id.Trim();
var normalizedPathPlayerId = ContractInstanceIds.NormalizePlayerId(trimmedId);
if (normalizedPathPlayerId.Length == 0 ||
!string.Equals(normalizedBodyPlayerId, normalizedPathPlayerId, StringComparison.Ordinal))
{
return Results.BadRequest();
}
if (!positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
var result = ContractGeneratorOperations.TryIssue(
trimmedId,
normalizedTemplateId,
normalizedSeedBucket,
body.ZoneDifficultyBand,
templateRegistry,
encounterRegistry,
itemRegistry,
skillRegistry,
factionRegistry,
instanceStore,
standingStore,
timeProvider);
return Results.Json(
MapIssueResponse(trimmedId, result, templateRegistry, deliveryStore));
});
return app;
}
internal static ContractIssueResponse MapIssueResponse(
string playerId,
ContractIssueOperationResult result,
IContractTemplateRegistry templateRegistry,
IRewardDeliveryStore deliveryStore)
{
ContractInstanceRowJson? contract = null;
if (result.Snapshot is { } snapshot)
{
contract = ContractListApi.MapInstanceRow(
playerId,
snapshot,
templateRegistry,
deliveryStore,
result.EncounterTemplateId);
}
return new ContractIssueResponse
{
Issued = result.Success,
ReasonCode = result.ReasonCode,
Contract = contract,
};
}
}

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,59 @@ 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 [];
}
ContractInstanceState? active = null;
if (TryGetActiveForPlayer(player, out var activeSnapshot))
{
active = activeSnapshot;
}
var completed = new List<ContractInstanceState>(maxCompletedRows);
if (maxCompletedRows > 0)
{
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at
FROM contract_instance
WHERE player_id = @pid AND status = 'completed'
ORDER BY issued_at DESC, contract_instance_id ASC
LIMIT @limit;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("limit", maxCompletedRows);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
completed.Add(ReadSnapshot(reader));
}
}
if (active is null)
{
return completed;
}
var result = new List<ContractInstanceState>(1 + completed.Count) { active };
result.AddRange(completed);
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

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

@ -281,6 +281,7 @@ Per-player contract runtime state lives in **`IContractInstanceStore`**, keyed b
- **`TryCreateActive`** — first active for player returns `true`; denies when another active exists, duplicate instance id, or player not writable.
- **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**.
- **`TryClearInstance`** — dev fixture only; removes one instance row.
- **`ListForPlayer`** — active row first (when present), then up to N recent **completed** rows ordered by **`issuedAt`** descending (NEO-151 HTTP read path).
Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation and selection run in **`ContractGeneratorOperations`** (NEO-147).
@ -316,7 +317,29 @@ Completed instances are immutable (no status regression). Player, template, and
**Result:** **`ContractIssueOperationResult`** — `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, **`encounterTemplateId`** from the selected template.
HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md), [NEO-150 implementation plan](../../docs/plans/NEO-150-implementation-plan.md).
## Contract issue HTTP (NEO-151)
**`POST /game/players/{id}/contracts/issue`** issues an **`active`** contract via **`ContractGeneratorOperations.TryIssue`**. Body (**`schemaVersion` 1**): **`playerId`** (must match path `{id}` after normalize), **`templateId`**, **`seedBucket`**, optional **`zoneDifficultyBand`**. Response: **`issued`**, optional **`reasonCode`**, optional **`contract`** row projection.
**Deny semantics:** **200** with **`issued: false`** + stable **`reasonCode`** (same codes as generator operations). **`active_contract_exists`** may echo the existing active **`contract`** row.
```bash
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/contracts/issue" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"playerId":"dev-local-1","templateId":"prototype_contract_clear_combat_pocket","seedBucket":"2026-06-28"}'
```
Bruno: `bruno/neon-sprawl-server/contracts/`. Plan: [NEO-151 implementation plan](../../docs/plans/NEO-151-implementation-plan.md).
## Per-player contract list HTTP (NEO-151)
**`GET /game/players/{id}/contracts`** returns **`schemaVersion` 1**, **`playerId`**, and **`contracts`**: the player's active instance (if any) plus up to **10** recent **completed** rows (**`issuedAt`** desc). Each row includes **`templateId`**, **`templateDisplayName`**, **`status`** (`active` \| `completed`), **`encounterTemplateId`**, **`seedBucket`**, **`issuedAt`**, optional **`completedAt`**, and optional **`completionRewardSummary`** (item/skill XP/reputation grant lines from **`IRewardDeliveryStore`** when **`contract_completion`** delivery exists — same nested shape as quest progress NEO-129/NEO-140).
```bash
curl -sS "http://localhost:5253/game/players/dev-local-1/contracts"
```
Unknown player ids return **404** (position gate). Bruno: `bruno/neon-sprawl-server/contracts/`.
## Contract completion operations (NEO-149)
@ -347,7 +370,7 @@ HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-1
**Prototype edge case:** issuing a contract after the bound encounter is already cleared leaves the instance **`active`** with no re-trigger in v1 — prototype flow is issue-then-clear.
HTTP contract GET/issue projections land in **NEO-151** (E7M4-08). Plan: [NEO-149 implementation plan](../../docs/plans/NEO-149-implementation-plan.md).
Plan: [NEO-149 implementation plan](../../docs/plans/NEO-149-implementation-plan.md). Contract HTTP GET/issue: [Per-player contract list HTTP (NEO-151)](#per-player-contract-list-http-neo-151).
## Contract outcome store (NEO-146)