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
parent
9994aee3cf
commit
c0252ee8be
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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}'."),
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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") ||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue