From c0252ee8be39a07ce4d24d7bb4bf432c3e1cf14f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 28 Jun 2026 10:21:10 -0400 Subject: [PATCH] NEO-151: Add contract issue POST and per-player contract GET HTTP API Wire ContractIssueApi and ContractListApi with ListForPlayer store reads, integration tests, Bruno issue/clear/GET spine, and docs reconciliation. --- ...et contract instance via quest fixture.bru | 2 +- .../Get contracts after encounter clear.bru | 136 +++++++ .../contracts/Get contracts default.bru | 35 ++ .../contracts/Issue prototype contract.bru | 56 +++ bruno/neon-sprawl-server/contracts/folder.bru | 8 + .../reset-contract-instances-helper.js | 30 ++ .../modules/E7_M4_ContractMissionGenerator.md | 2 + ...umentation_and_implementation_alignment.md | 2 +- docs/plans/NEO-151-implementation-plan.md | 17 +- .../Game/Contracts/ContractApiTests.cs | 361 ++++++++++++++++++ .../ContractGeneratorOperationsTests.cs | 3 + .../InMemoryContractInstanceStoreTests.cs | 121 ++++++ .../Game/Contracts/ContractApiDtos.cs | 95 +++++ .../Game/Contracts/ContractIssueApi.cs | 97 +++++ .../Game/Contracts/ContractListApi.cs | 95 +++++ .../Game/Contracts/IContractInstanceStore.cs | 6 + .../InMemoryContractInstanceStore.cs | 56 +++ .../PostgresContractInstanceStore.cs | 53 +++ .../Game/Quests/QuestProgressApi.cs | 20 +- server/NeonSprawl.Server/Program.cs | 2 + server/README.md | 27 +- 21 files changed, 1213 insertions(+), 11 deletions(-) create mode 100644 bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru create mode 100644 bruno/neon-sprawl-server/contracts/Get contracts default.bru create mode 100644 bruno/neon-sprawl-server/contracts/Issue prototype contract.bru create mode 100644 bruno/neon-sprawl-server/contracts/folder.bru create mode 100644 bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs diff --git a/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru b/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru index 4ac34c8..5cfed57 100644 --- a/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru +++ b/bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru @@ -21,7 +21,7 @@ body:json { docs { NEO-149: quest-fixture accepts resetContractInstanceIds — clears contract instance, contract_completion delivery row, and outcome audit for each id (idempotent when absent). - Full issue → clear → GET Bruno loop lands in NEO-151 when contract HTTP ships. + Full issue → clear → GET Bruno loop: `bruno/neon-sprawl-server/contracts/` (NEO-151). Reset below uses placeholder id when no prior issue bru ran in-session. } tests { diff --git a/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru b/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru new file mode 100644 index 0000000..ca8dc11 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru @@ -0,0 +1,136 @@ +meta { + name: GET contracts after encounter clear + type: http + seq: 3 +} + +docs { + NEO-151 AC: issue prototype contract, clear prototype_combat_pocket via three-NPC defeat spine, GET contracts shows completed row with completionRewardSummary. +} + +script:pre-request { + const axios = require("axios"); + const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper"); + const { resetPrototypeCombatTargets } = require("../encounter-progress/scripts/combat-targets-reset-helper.js"); + const { + moveNearPrototypeNpc, + PULSE_COOLDOWN_MS, + MAX_PULSE_DEFEAT_ATTEMPTS, + sleep, + } = require("../encounter-progress/scripts/prototype-npc-bruno-helper.js"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + const slotIndex = 3; + + async function defeatNpc(targetId) { + await moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders); + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId }, + jsonHeaders, + ); + await sleep(bru, PULSE_COOLDOWN_MS); + const castBody = { + schemaVersion: 1, + slotIndex, + abilityId: "prototype_pulse", + targetId, + }; + for (let i = 0; i < MAX_PULSE_DEFEAT_ATTEMPTS; i++) { + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + castBody, + jsonHeaders, + ); + const body = response.data; + if (!body?.accepted || !body?.combatResolution) { + throw new Error( + `cast ${i + 1} vs ${targetId} failed (${body?.reasonCode ?? "no_reason"}): ${JSON.stringify(body)}`, + ); + } + if (body.combatResolution.targetDefeated) { + return; + } + if (i < MAX_PULSE_DEFEAT_ATTEMPTS - 1) { + await sleep(bru, PULSE_COOLDOWN_MS); + } + } + throw new Error( + `did not defeat ${targetId} within ${MAX_PULSE_DEFEAT_ATTEMPTS} pulses`, + ); + } + + await resetAllContractInstances(bru); + await resetPrototypeCombatTargets(bru); + + const issueResponse = await axios.post( + `${baseUrl}/game/players/${playerId}/contracts/issue`, + { + schemaVersion: 1, + playerId, + templateId: "prototype_contract_clear_combat_pocket", + seedBucket: "2026-06-28-neo151-bruno-clear", + }, + jsonHeaders, + ); + if (issueResponse.status !== 200 || !issueResponse.data?.issued) { + throw new Error(`contract issue failed: ${JSON.stringify(issueResponse.data)}`); + } + bru.setVar("neo151ContractInstanceId", issueResponse.data.contract.contractInstanceId); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await defeatNpc("prototype_npc_melee"); + await defeatNpc("prototype_npc_ranged"); + await defeatNpc("prototype_npc_elite"); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/contracts + body: none + auth: none +} + +script:post-response { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const second = await axios.get(`${baseUrl}/game/players/${playerId}/contracts`); + bru.setVar("neo151SecondContractsGetJson", JSON.stringify(second.data)); +} + +tests { + test("completed contract row includes delivery summary", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + const instanceId = bru.getVar("neo151ContractInstanceId"); + const row = body.contracts.find((x) => x.contractInstanceId === instanceId); + expect(row).to.be.an("object"); + expect(row.status).to.equal("completed"); + expect(row.encounterTemplateId).to.equal("prototype_combat_pocket"); + expect(row.completedAt).to.be.a("string"); + expect(row.completionRewardSummary).to.eql({ + itemGrants: [{ itemId: "scrap_metal_bulk", quantity: 5 }], + skillXpGrants: [{ skillId: "salvage", amount: 15 }], + reputationGrants: [], + }); + }); + + test("second GET completionRewardSummary matches first GET", function () { + const first = res.getBody(); + const second = JSON.parse(bru.getVar("neo151SecondContractsGetJson")); + const instanceId = bru.getVar("neo151ContractInstanceId"); + const firstRow = first.contracts.find((x) => x.contractInstanceId === instanceId); + const secondRow = second.contracts.find((x) => x.contractInstanceId === instanceId); + expect(secondRow.completionRewardSummary).to.eql(firstRow.completionRewardSummary); + }); +} diff --git a/bruno/neon-sprawl-server/contracts/Get contracts default.bru b/bruno/neon-sprawl-server/contracts/Get contracts default.bru new file mode 100644 index 0000000..8e9617f --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/Get contracts default.bru @@ -0,0 +1,35 @@ +meta { + name: GET contracts default + type: http + seq: 1 +} + +docs { + NEO-151: fresh dev-local-1 contract list before issue — empty array when no instances. + Pre-request clears any leftover contract rows via quest-fixture reset. +} + +script:pre-request { + const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper"); + await resetAllContractInstances(bru); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/contracts + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); + expect(body.contracts).to.be.an("array"); + }); + + test("no contract instances by default", function () { + expect(res.getBody().contracts).to.eql([]); + }); +} diff --git a/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru b/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru new file mode 100644 index 0000000..bef7147 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/Issue prototype contract.bru @@ -0,0 +1,56 @@ +meta { + name: Issue prototype contract + type: http + seq: 2 +} + +docs { + NEO-151: POST issue with frozen prototype template + seed bucket; returns active instance with encounter objective id. +} + +script:pre-request { + const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper"); + await resetAllContractInstances(bru); +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/contracts/issue + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "playerId": "{{playerId}}", + "templateId": "prototype_contract_clear_combat_pocket", + "seedBucket": "2026-06-28-neo151-bruno" + } +} + +script:post-response { + const body = res.getBody(); + if (body?.contract?.contractInstanceId) { + bru.setVar("neo151ContractInstanceId", body.contract.contractInstanceId); + } +} + +tests { + test("returns 200 with issued true", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.issued).to.equal(true); + expect(body.reasonCode).to.equal(undefined); + }); + + test("active contract row includes encounter objective id", function () { + const contract = res.getBody().contract; + expect(contract).to.be.an("object"); + expect(contract.status).to.equal("active"); + expect(contract.templateId).to.equal("prototype_contract_clear_combat_pocket"); + expect(contract.encounterTemplateId).to.equal("prototype_combat_pocket"); + expect(contract.contractInstanceId).to.be.a("string"); + expect(contract.completionRewardSummary).to.equal(undefined); + }); +} diff --git a/bruno/neon-sprawl-server/contracts/folder.bru b/bruno/neon-sprawl-server/contracts/folder.bru new file mode 100644 index 0000000..30eea97 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/folder.bru @@ -0,0 +1,8 @@ +meta { + name: contracts +} + +docs { + NEO-151: contract issue POST + per-player contract GET. + Cross-link: bruno/neon-sprawl-server/encounter-progress/ for encounter clear spine. +} diff --git a/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js b/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js new file mode 100644 index 0000000..18cda66 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/scripts/reset-contract-instances-helper.js @@ -0,0 +1,30 @@ +async function resetAllContractInstances(bru) { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + + const listResponse = await axios.get( + `${baseUrl}/game/players/${playerId}/contracts`, + jsonHeaders, + ); + if (listResponse.status !== 200) { + throw new Error(`contracts GET failed: ${listResponse.status}`); + } + + const ids = (listResponse.data.contracts || []).map((row) => row.contractInstanceId); + if (ids.length === 0) { + return; + } + + await axios.post( + `${baseUrl}/game/players/${playerId}/__dev/quest-fixture`, + { + schemaVersion: 1, + resetContractInstanceIds: ids, + }, + jsonHeaders, + ); +} + +module.exports = { resetAllContractInstances }; diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index e16f9b3..0076acf 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -67,6 +67,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies. **Contract completion wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — encounter clear completes matching active instance + outcome audit; [server README — Contract completion operations](../../../server/README.md#contract-completion-operations-neo-149). +**Contract HTTP (NEO-151):** **`POST …/contracts/issue`** + **`GET …/contracts`** — issue envelope + per-player list with **`encounterTemplateId`** and **`completionRewardSummary`** on completed rows; Bruno `bruno/neon-sprawl-server/contracts/`; [server README — Contract issue HTTP (NEO-151)](../../../server/README.md#contract-issue-http-neo-151), [Per-player contract list HTTP (NEO-151)](../../../server/README.md#per-player-contract-list-http-neo-151); plan [NEO-151](../../plans/NEO-151-implementation-plan.md). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index c292b62..00b4074 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -62,7 +62,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | | E7.M3 | Ready | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08 quest HTTP projections landed ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)):** world GET **`factionGateRules`** + quest-progress **`completionRewardSummary.reputationGrants`**; Bruno quest-definitions + operator-chain progress bru ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115), [Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119). **E7M3-09 telemetry landed ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)):** comment-only **`reputation_delta`** + **`faction_gate_blocked`** hook sites in ops layers ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141). **E7M3-10 client faction HUD landed ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)):** `faction_standing_client.gd`, standing label + gate deny + rep reward lines ([NEO-142 plan](../../plans/NEO-142-implementation-plan.md)); [client README — Faction standing + gate feedback HUD (NEO-142)](../../../client/README.md#faction-standing--gate-feedback-hud-neo-142); [`NEO-142` manual QA](../../manual-qa/NEO-142.md). **E7M3-11 client capstone landed ([NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)):** playable faction reputation + gate capstone — [`NEO-143` manual QA](../../manual-qa/NEO-143.md); [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143); plan [NEO-143](../../plans/NEO-143-implementation-plan.md). **Epic 7 Slice 3 client capstone complete.** Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**. Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [NEO-140 plan](../../plans/NEO-140-implementation-plan.md), [NEO-141 plan](../../plans/NEO-141-implementation-plan.md), [NEO-142 plan](../../plans/NEO-142-implementation-plan.md), [NEO-143 plan](../../plans/NEO-143-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | -| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M4-06 completion wiring landed ([NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)):** `ContractCompletionOperations.TryCompleteOnEncounterClear`, encounter hook after E5.M3 loot + quest wiring, `QuestFixtureRequest.resetContractInstanceIds` ([NEO-149 plan](../../plans/NEO-149-implementation-plan.md)); [server README — Contract completion operations (NEO-149)](../../../server/README.md#contract-completion-operations-neo-149); Bruno `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru`. **E7M4-07 economy validation landed ([NEO-150](https://linear.app/neon-sprawl/issue/NEO-150)):** `ContractEconomyValidation.TryValidateTemplate` at issue — band caps + cross-ref deny codes ([NEO-150 plan](../../plans/NEO-150-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), [NEO-149](../../plans/NEO-149-implementation-plan.md), [NEO-150](../../plans/NEO-150-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | +| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M4-06 completion wiring landed ([NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)):** `ContractCompletionOperations.TryCompleteOnEncounterClear`, encounter hook after E5.M3 loot + quest wiring, `QuestFixtureRequest.resetContractInstanceIds` ([NEO-149 plan](../../plans/NEO-149-implementation-plan.md)); [server README — Contract completion operations (NEO-149)](../../../server/README.md#contract-completion-operations-neo-149); Bruno `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru`. **E7M4-07 economy validation landed ([NEO-150](https://linear.app/neon-sprawl/issue/NEO-150)):** `ContractEconomyValidation.TryValidateTemplate` at issue — band caps + cross-ref deny codes ([NEO-150 plan](../../plans/NEO-150-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-08 contract HTTP landed ([NEO-151](https://linear.app/neon-sprawl/issue/NEO-151)):** `POST …/contracts/issue` + `GET …/contracts` — issue envelope, active + recent completed list, `completionRewardSummary` on completed rows ([NEO-151 plan](../../plans/NEO-151-implementation-plan.md)); [server README — Contract issue HTTP (NEO-151)](../../../server/README.md#contract-issue-http-neo-151); Bruno `bruno/neon-sprawl-server/contracts/`. **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), [NEO-149](../../plans/NEO-149-implementation-plan.md), [NEO-150](../../plans/NEO-150-implementation-plan.md), [NEO-151](../../plans/NEO-151-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | --- diff --git a/docs/plans/NEO-151-implementation-plan.md b/docs/plans/NEO-151-implementation-plan.md index 0ad11ca..0af6c1f 100644 --- a/docs/plans/NEO-151-implementation-plan.md +++ b/docs/plans/NEO-151-implementation-plan.md @@ -48,9 +48,18 @@ Client-readable contract issuance and state without local math — **`POST …/c ## Acceptance criteria checklist -- [ ] POST issue returns **`active`** instance JSON. -- [ ] GET lists active contract with encounter objective id. -- [ ] GET completed row includes delivery summary after encounter clear. +- [x] POST issue returns **`active`** instance JSON. +- [x] GET lists active contract with encounter objective id. +- [x] GET completed row includes delivery summary after encounter clear. + +## Implementation reconciliation (shipped) + +- **`IContractInstanceStore.ListForPlayer`** — active first + up to 10 completed rows (`issuedAt` desc); in-memory + Postgres impls. +- **`ContractIssueApi`** — `POST …/contracts/issue` delegates to **`ContractGeneratorOperations.TryIssue`** with full registry DI. +- **`ContractListApi`** — `GET …/contracts` with **`completionRewardSummary`** via shared **`QuestProgressApi`** delivery mapper. +- **Tests:** 5 store list unit tests + 10 HTTP integration tests; **927** tests green. +- **Bruno:** `bruno/neon-sprawl-server/contracts/` — default GET, issue, encounter-clear capstone. +- **Docs:** `server/README.md`, `E7_M4_ContractMissionGenerator.md`, alignment register. ## Technical approach @@ -186,7 +195,7 @@ Manual Godot QA: **none** (server HTTP; capstone **NEO-154**). Bruno: **`contrac | Question / risk | Agent recommendation | Status | |-----------------|----------------------|--------| | **`TryIssue` registry wiring in HTTP handler** | Pass same four registries as **`ContractGeneratorOperationsIntegrationTests`** — verified at kickoff (NEO-150 handoff) | `adopted` | -| Duplicate grant-summary mapper vs quest API | Extract **`internal`** shared mapper on **`QuestProgressApi`** if clean; else parallel private helpers — keep grant order parity | `pending` | +| Duplicate grant-summary mapper vs quest API | **`QuestProgressApi.MapCompletionRewardSummary`** extended with `sourceKind` + `sourceId`; contract API reuses it | `adopted` | | GET payload size with 10 completed rows | Accept for prototype; client (**NEO-153**) reads active row first | `adopted` | | **`contract-seed.schema.json` lacks `schemaVersion`** | HTTP DTO adds **`schemaVersion`** alongside seed fields (quest/inventory precedent); schema file unchanged unless CI needs `$ref` later | `adopted` | | Postgres **`ListForPlayer`** index | Existing **`player_id`** + status filter sufficient for prototype volume; no new migration | `adopted` | diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs new file mode 100644 index 0000000..d0beca4 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs @@ -0,0 +1,361 @@ +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public sealed class ContractApiTests +{ + private const string PlayerId = "dev-local-1"; + private const string TemplateId = "prototype_contract_clear_combat_pocket"; + private const string EncounterId = "prototype_combat_pocket"; + private const string SeedBucket = "2026-06-28-neo151"; + private const string DisplayName = "Clear Combat Pocket (Repeat)"; + private static readonly DateTimeOffset CompletedAt = new(2026, 6, 28, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + "/game/players/missing-player/contracts/issue", + JsonContent.Create(CreateIssueRequest("missing-player"))); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnBadRequest_WhenPlayerIdMismatch() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest("other-player"))); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnBadRequest_WhenSchemaVersionMismatch() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create( + new ContractIssueRequest + { + SchemaVersion = 99, + PlayerId = PlayerId, + TemplateId = TemplateId, + SeedBucket = SeedBucket, + })); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnIssuedTrue_WhenPrototypeTemplateEligible() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId( + PlayerId, + TemplateId, + SeedBucket); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(ContractIssueResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.True(body.Issued); + Assert.Null(body.ReasonCode); + Assert.NotNull(body.Contract); + Assert.Equal(expectedInstanceId, body.Contract!.ContractInstanceId); + Assert.Equal(TemplateId, body.Contract.TemplateId); + Assert.Equal(DisplayName, body.Contract.TemplateDisplayName); + Assert.Equal(ContractListApi.StatusActive, body.Contract.Status); + Assert.Equal(EncounterId, body.Contract.EncounterTemplateId); + Assert.Equal(SeedBucket, body.Contract.SeedBucket); + Assert.Null(body.Contract.CompletedAt); + Assert.Null(body.Contract.CompletionRewardSummary); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnIssuedFalse_WhenActiveContractExists() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var first = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create( + new ContractIssueRequest + { + SchemaVersion = ContractIssueRequest.CurrentSchemaVersion, + PlayerId = PlayerId, + TemplateId = TemplateId, + SeedBucket = "2026-06-28-neo151-second", + })); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Issued); + Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, body.ReasonCode); + Assert.NotNull(body.Contract); + Assert.Equal(ContractListApi.StatusActive, body.Contract!.Status); + } + + [Fact] + public async Task GetContracts_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/missing-player/contracts"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetContracts_ShouldReturnEmptyList_WhenNoInstances() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(ContractListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal(PlayerId, body.PlayerId); + Assert.Empty(body.Contracts); + } + + [Fact] + public async Task GetContracts_ShouldReturnActiveRowAfterIssue_WithEncounterObjectiveId() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var issue = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + Assert.Equal(HttpStatusCode.OK, issue.StatusCode); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Contracts); + Assert.Equal(ContractListApi.StatusActive, row.Status); + Assert.Equal(EncounterId, row.EncounterTemplateId); + Assert.Null(row.CompletionRewardSummary); + } + + [Fact] + public async Task GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var deps = ResolveDependencies(factory); + var issue = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + Assert.Equal(HttpStatusCode.OK, issue.StatusCode); + var issueBody = await issue.Content.ReadFromJsonAsync(); + Assert.NotNull(issueBody?.Contract); + var instanceId = issueBody!.Contract!.ContractInstanceId; + + // Act + var completed = ContractCompletionOperations.TryCompleteOnEncounterClear( + PlayerId, + EncounterId, + deps.InstanceStore, + deps.OutcomeStore, + deps.TemplateRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, + deps.TimeProvider); + var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + + // Assert + Assert.True(completed.Success); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Contracts); + Assert.Equal(instanceId, row.ContractInstanceId); + Assert.Equal(ContractListApi.StatusCompleted, row.Status); + Assert.NotNull(row.CompletedAt); + AssertPrototypeCompletionRewardSummary(row.CompletionRewardSummary); + } + + [Fact] + public async Task GetContracts_ShouldReturnSameCompletionRewardSummary_WhenReplayed() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var deps = ResolveDependencies(factory); + _ = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + _ = ContractCompletionOperations.TryCompleteOnEncounterClear( + PlayerId, + EncounterId, + deps.InstanceStore, + deps.OutcomeStore, + deps.TemplateRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, + deps.TimeProvider); + + // Act + var first = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + var second = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + + // Assert + var firstBody = await first.Content.ReadFromJsonAsync(); + var secondBody = await second.Content.ReadFromJsonAsync(); + Assert.NotNull(firstBody); + Assert.NotNull(secondBody); + AssertCompletionRewardSummariesEqual( + Assert.Single(firstBody!.Contracts).CompletionRewardSummary, + Assert.Single(secondBody!.Contracts).CompletionRewardSummary); + } + + private static ContractIssueRequest CreateIssueRequest(string playerId) => + new() + { + SchemaVersion = ContractIssueRequest.CurrentSchemaVersion, + PlayerId = playerId, + TemplateId = TemplateId, + SeedBucket = SeedBucket, + }; + + private static void AssertPrototypeCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary) + { + Assert.NotNull(summary); + var item = Assert.Single(summary!.ItemGrants); + Assert.Equal("scrap_metal_bulk", item.ItemId); + Assert.Equal(5, item.Quantity); + var skill = Assert.Single(summary.SkillXpGrants); + Assert.Equal("salvage", skill.SkillId); + Assert.Equal(15, skill.Amount); + Assert.Empty(summary.ReputationGrants); + } + + private static void AssertCompletionRewardSummariesEqual( + QuestCompletionRewardSummaryJson? first, + QuestCompletionRewardSummaryJson? second) + { + Assert.NotNull(first); + Assert.NotNull(second); + AssertPrototypeCompletionRewardSummary(first); + AssertPrototypeCompletionRewardSummary(second); + } + + private static ApiTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) + { + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + return new ApiTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + new FakeTimeProvider(CompletedAt)); + } + + private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } + + private sealed record ApiTestDependencies( + IContractTemplateRegistry TemplateRegistry, + IContractInstanceStore InstanceStore, + IContractOutcomeStore OutcomeStore, + IItemDefinitionRegistry ItemRegistry, + IPlayerInventoryStore InventoryStore, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore SkillProgressionStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore, + TimeProvider TimeProvider); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs index fca68a5..bd8e527 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs @@ -407,5 +407,8 @@ public sealed class ContractGeneratorOperationsTests public bool TryClearInstance(string playerId, string contractInstanceId) => inner.TryClearInstance(playerId, contractInstanceId); + + public IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows) => + inner.ListForPlayer(playerId, maxCompletedRows); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs index cf6d087..707980d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs @@ -312,6 +312,127 @@ public sealed class InMemoryContractInstanceStoreTests second.Status == ContractInstanceStatus.Active); } + [Fact] + public void ListForPlayer_ShouldReturnEmpty_WhenPlayerHasNoInstances() + { + // Arrange + var store = CreateStore(); + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + // Assert + Assert.Empty(rows); + } + + [Fact] + public void ListForPlayer_ShouldReturnEmpty_ForUnknownPlayer() + { + // Arrange + var store = CreateStore(); + // Act + var rows = store.ListForPlayer(UnknownPlayerId, maxCompletedRows: 10); + // Assert + Assert.Empty(rows); + } + + [Fact] + public void ListForPlayer_ShouldReturnActiveOnly_WhenNoCompletedRows() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + // Assert + var row = Assert.Single(rows); + Assert.Equal(InstanceId, row.ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, row.Status); + } + + [Fact] + public void ListForPlayer_ShouldReturnCompletedOrderedByIssuedAt_WhenNoActive() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryCreateActive( + PlayerId, + InstanceId, + TemplateId, + SeedBucket, + IssuedAt, + out _)); + Assert.True(store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _)); + Assert.True(store.TryCreateActive( + PlayerId, + SecondInstanceId, + TemplateId, + SeedBucket, + IssuedAt.AddDays(1), + out _)); + Assert.True(store.TryMarkComplete( + PlayerId, + SecondInstanceId, + CompletedAt.AddDays(1), + out _)); + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + // Assert + Assert.Equal(2, rows.Count); + Assert.Equal(SecondInstanceId, rows[0].ContractInstanceId); + Assert.Equal(InstanceId, rows[1].ContractInstanceId); + } + + [Fact] + public void ListForPlayer_ShouldPrependActiveAndCapCompletedRows() + { + // Arrange + var store = CreateStore(); + for (var i = 0; i < 12; i++) + { + var instanceId = $"prototype_contract_instance_done_{i:D2}"; + Assert.True(store.TryCreateActive( + PlayerId, + instanceId, + TemplateId, + SeedBucket, + IssuedAt.AddHours(i), + out _)); + Assert.True(store.TryMarkComplete( + PlayerId, + instanceId, + CompletedAt.AddHours(i), + out _)); + } + + const string activeId = "prototype_contract_instance_active"; + Assert.True(store.TryCreateActive( + PlayerId, + activeId, + TemplateId, + SeedBucket, + IssuedAt.AddDays(3), + out _)); + + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + // Assert + Assert.Equal(11, rows.Count); + Assert.Equal(activeId, rows[0].ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, rows[0].Status); + Assert.All(rows.Skip(1), row => Assert.Equal(ContractInstanceStatus.Completed, row.Status)); + Assert.DoesNotContain( + rows.Skip(1), + row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_done_00", StringComparison.Ordinal)); + Assert.DoesNotContain( + rows.Skip(1), + row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_done_01", StringComparison.Ordinal)); + } + [Fact] public void TryClearInstance_ShouldReturnTrue_WhenRowMissing() { diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs b/server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs new file mode 100644 index 0000000..3aa9789 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization; +using NeonSprawl.Server.Game.Quests; + +namespace NeonSprawl.Server.Game.Contracts; + +/// POST body for POST /game/players/{{id}}/contracts/issue (NEO-151). +public sealed class ContractIssueRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("playerId")] + public string? PlayerId { get; init; } + + [JsonPropertyName("templateId")] + public string? TemplateId { get; init; } + + [JsonPropertyName("seedBucket")] + public string? SeedBucket { get; init; } + + [JsonPropertyName("zoneDifficultyBand")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ZoneDifficultyBand { get; init; } +} + +/// POST response for contract issue (NEO-151). +public sealed class ContractIssueResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("issued")] + public bool Issued { get; init; } + + [JsonPropertyName("reasonCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasonCode { get; init; } + + [JsonPropertyName("contract")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ContractInstanceRowJson? Contract { get; init; } +} + +/// JSON body for GET /game/players/{{id}}/contracts (NEO-151). +public sealed class ContractListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + [JsonPropertyName("contracts")] + public required IReadOnlyList Contracts { get; init; } +} + +/// Authoritative contract instance projection for HTTP clients (NEO-151). +public sealed class ContractInstanceRowJson +{ + [JsonPropertyName("contractInstanceId")] + public required string ContractInstanceId { get; init; } + + [JsonPropertyName("templateId")] + public required string TemplateId { get; init; } + + [JsonPropertyName("templateDisplayName")] + public required string TemplateDisplayName { get; init; } + + /// active or completed. + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("encounterTemplateId")] + public required string EncounterTemplateId { get; init; } + + [JsonPropertyName("seedBucket")] + public required string SeedBucket { get; init; } + + [JsonPropertyName("issuedAt")] + public DateTimeOffset IssuedAt { get; init; } + + [JsonPropertyName("completedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CompletedAt { get; init; } + + [JsonPropertyName("completionRewardSummary")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs b/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs new file mode 100644 index 0000000..4877e38 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs @@ -0,0 +1,97 @@ +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Maps POST /game/players/{{id}}/contracts/issue (NEO-151). +public static class ContractIssueApi +{ + public static WebApplication MapContractIssueApi(this WebApplication app) + { + app.MapPost( + "/game/players/{id}/contracts/issue", + (string id, ContractIssueRequest? body, IPositionStateStore positions, + IContractTemplateRegistry templateRegistry, IEncounterDefinitionRegistry encounterRegistry, + IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry, + IFactionDefinitionRegistry factionRegistry, IContractInstanceStore instanceStore, + IFactionStandingStore standingStore, IRewardDeliveryStore deliveryStore, + TimeProvider timeProvider) => + { + if (body is null || body.SchemaVersion != ContractIssueRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(body.TemplateId); + var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(body.SeedBucket); + var normalizedBodyPlayerId = ContractInstanceIds.NormalizePlayerId(body.PlayerId); + if (normalizedTemplateId.Length == 0 || + normalizedSeedBucket.Length == 0 || + normalizedBodyPlayerId.Length == 0) + { + return Results.BadRequest(); + } + + var trimmedId = id.Trim(); + var normalizedPathPlayerId = ContractInstanceIds.NormalizePlayerId(trimmedId); + if (normalizedPathPlayerId.Length == 0 || + !string.Equals(normalizedBodyPlayerId, normalizedPathPlayerId, StringComparison.Ordinal)) + { + return Results.BadRequest(); + } + + if (!positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + var result = ContractGeneratorOperations.TryIssue( + trimmedId, + normalizedTemplateId, + normalizedSeedBucket, + body.ZoneDifficultyBand, + templateRegistry, + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry, + instanceStore, + standingStore, + timeProvider); + + return Results.Json( + MapIssueResponse(trimmedId, result, templateRegistry, deliveryStore)); + }); + + return app; + } + + internal static ContractIssueResponse MapIssueResponse( + string playerId, + ContractIssueOperationResult result, + IContractTemplateRegistry templateRegistry, + IRewardDeliveryStore deliveryStore) + { + ContractInstanceRowJson? contract = null; + if (result.Snapshot is { } snapshot) + { + contract = ContractListApi.MapInstanceRow( + playerId, + snapshot, + templateRegistry, + deliveryStore, + result.EncounterTemplateId); + } + + return new ContractIssueResponse + { + Issued = result.Success, + ReasonCode = result.ReasonCode, + Contract = contract, + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs b/server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs new file mode 100644 index 0000000..c5253ef --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs @@ -0,0 +1,95 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Maps GET /game/players/{{id}}/contracts (NEO-151). +public static class ContractListApi +{ + public const int MaxCompletedRows = 10; + + public const string StatusActive = "active"; + public const string StatusCompleted = "completed"; + + public static WebApplication MapContractListApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/contracts", + (string id, IPositionStateStore positions, IContractInstanceStore instanceStore, + IContractTemplateRegistry templateRegistry, IRewardDeliveryStore deliveryStore) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + var snapshots = instanceStore.ListForPlayer(trimmedId, MaxCompletedRows); + var rows = new List(snapshots.Count); + foreach (var snapshot in snapshots) + { + rows.Add(MapInstanceRow(trimmedId, snapshot, templateRegistry, deliveryStore)); + } + + return Results.Json( + new ContractListResponse + { + PlayerId = trimmedId, + Contracts = rows, + }); + }); + + return app; + } + + internal static ContractInstanceRowJson MapInstanceRow( + string playerId, + ContractInstanceState snapshot, + IContractTemplateRegistry templateRegistry, + IRewardDeliveryStore deliveryStore, + string? encounterTemplateIdOverride = null) + { + var templateDisplayName = snapshot.TemplateId; + var encounterTemplateId = encounterTemplateIdOverride ?? string.Empty; + if (templateRegistry.TryGetDefinition(snapshot.TemplateId, out var template)) + { + templateDisplayName = template.DisplayName; + if (encounterTemplateId.Length == 0) + { + encounterTemplateId = template.EncounterTemplateId; + } + } + + QuestCompletionRewardSummaryJson? completionRewardSummary = null; + if (snapshot.Status == ContractInstanceStatus.Completed) + { + completionRewardSummary = QuestProgressApi.MapCompletionRewardSummary( + playerId, + RewardDeliverySourceKinds.ContractCompletion, + snapshot.ContractInstanceId, + deliveryStore); + } + + return new ContractInstanceRowJson + { + ContractInstanceId = snapshot.ContractInstanceId, + TemplateId = snapshot.TemplateId, + TemplateDisplayName = templateDisplayName, + Status = MapStatus(snapshot.Status), + EncounterTemplateId = encounterTemplateId, + SeedBucket = snapshot.SeedBucket, + IssuedAt = snapshot.IssuedAt, + CompletedAt = snapshot.CompletedAt, + CompletionRewardSummary = completionRewardSummary, + }; + } + + private static string MapStatus(ContractInstanceStatus status) => + status switch + { + ContractInstanceStatus.Active => StatusActive, + ContractInstanceStatus.Completed => StatusCompleted, + _ => throw new InvalidOperationException($"Unknown contract instance status '{status}'."), + }; +} diff --git a/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs index 6bb48b6..7364700 100644 --- a/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs @@ -36,4 +36,10 @@ public interface IContractInstanceStore /// Dev fixture only — removes one instance row; idempotent when absent. bool TryClearInstance(string playerId, string contractInstanceId); + + /// + /// Active row first (when present), then up to completed rows + /// for the player ordered by descending. + /// + IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows); } diff --git a/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs index 36591ca..1cac067 100644 --- a/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs @@ -240,6 +240,62 @@ public sealed class InMemoryContractInstanceStore(IOptions } } + /// + public IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows) + { + var player = ContractInstanceIds.NormalizePlayerId(playerId); + if (player.Length == 0 || !knownPlayers.Contains(player) || maxCompletedRows < 0) + { + return []; + } + + lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object())) + { + ContractInstanceState? active = null; + if (activeInstanceByPlayer.TryGetValue(player, out var activeId) && + byInstanceId.TryGetValue(activeId, out var activeRow) && + activeRow.Status == ContractInstanceStatus.Active && + string.Equals(activeRow.PlayerId, player, StringComparison.Ordinal)) + { + active = activeRow.ToSnapshot(); + } + + var completed = new List(); + foreach (var row in byInstanceId.Values) + { + if (!string.Equals(row.PlayerId, player, StringComparison.Ordinal) || + row.Status != ContractInstanceStatus.Completed) + { + continue; + } + + completed.Add(row.ToSnapshot()); + } + + completed.Sort(static (a, b) => + { + var cmp = b.IssuedAt.CompareTo(a.IssuedAt); + return cmp != 0 + ? cmp + : string.Compare(a.ContractInstanceId, b.ContractInstanceId, StringComparison.Ordinal); + }); + + if (completed.Count > maxCompletedRows) + { + completed.RemoveRange(maxCompletedRows, completed.Count - maxCompletedRows); + } + + if (active is null) + { + return completed; + } + + var result = new List(1 + completed.Count) { active }; + result.AddRange(completed); + return result; + } + } + /// public bool TryClearInstance(string playerId, string contractInstanceId) { diff --git a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs index 99945fc..d4f481e 100644 --- a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs @@ -314,6 +314,59 @@ public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSo return true; } + /// + public IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows) + { + var player = ContractInstanceIds.NormalizePlayerId(playerId); + if (player.Length == 0 || maxCompletedRows < 0) + { + return []; + } + + PostgresContractInstanceBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + if (!PlayerExists(conn, player)) + { + return []; + } + + ContractInstanceState? active = null; + if (TryGetActiveForPlayer(player, out var activeSnapshot)) + { + active = activeSnapshot; + } + + var completed = new List(maxCompletedRows); + if (maxCompletedRows > 0) + { + using var cmd = new Npgsql.NpgsqlCommand( + """ + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM contract_instance + WHERE player_id = @pid AND status = 'completed' + ORDER BY issued_at DESC, contract_instance_id ASC + LIMIT @limit; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + cmd.Parameters.AddWithValue("limit", maxCompletedRows); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + completed.Add(ReadSnapshot(reader)); + } + } + + if (active is null) + { + return completed; + } + + var result = new List(1 + completed.Count) { active }; + result.AddRange(completed); + return result; + } + /// public bool TryClearInstance(string playerId, string contractInstanceId) { diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs index bd35646..c1c313b 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs @@ -121,20 +121,34 @@ public static class QuestProgressApi private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary( string playerId, string questId, + IRewardDeliveryStore deliveryStore) => + MapCompletionRewardSummary( + playerId, + RewardDeliverySourceKinds.QuestCompletion, + questId, + deliveryStore); + + internal static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary( + string playerId, + string sourceKind, + string sourceId, IRewardDeliveryStore deliveryStore) { - if (!deliveryStore.TryGet(playerId, questId, out var deliveryEvent)) + if (!deliveryStore.TryGet(playerId, sourceKind, sourceId, out var deliveryEvent)) { return null; } - return new QuestCompletionRewardSummaryJson + return MapCompletionRewardSummary(deliveryEvent); + } + + internal static QuestCompletionRewardSummaryJson MapCompletionRewardSummary(RewardDeliveryEvent deliveryEvent) => + new() { ItemGrants = MapItemGrants(deliveryEvent.GrantedItems), SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp), ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation), }; - } /// Preserves commit-time grant order from . private static List MapItemGrants(IReadOnlyList grants) diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index c37a3f8..6990680 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -90,6 +90,8 @@ app.MapFactionStandingApi(); app.MapEncounterProgressApi(); app.MapQuestProgressApi(); app.MapQuestAcceptApi(); +app.MapContractIssueApi(); +app.MapContractListApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") || diff --git a/server/README.md b/server/README.md index 57d5db3..ae6b3a4 100644 --- a/server/README.md +++ b/server/README.md @@ -281,6 +281,7 @@ Per-player contract runtime state lives in **`IContractInstanceStore`**, keyed b - **`TryCreateActive`** — first active for player returns `true`; denies when another active exists, duplicate instance id, or player not writable. - **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**. - **`TryClearInstance`** — dev fixture only; removes one instance row. +- **`ListForPlayer`** — active row first (when present), then up to N recent **completed** rows ordered by **`issuedAt`** descending (NEO-151 HTTP read path). Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation and selection run in **`ContractGeneratorOperations`** (NEO-147). @@ -316,7 +317,29 @@ Completed instances are immutable (no status regression). Player, template, and **Result:** **`ContractIssueOperationResult`** — `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, **`encounterTemplateId`** from the selected template. -HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md), [NEO-150 implementation plan](../../docs/plans/NEO-150-implementation-plan.md). +## Contract issue HTTP (NEO-151) + +**`POST /game/players/{id}/contracts/issue`** issues an **`active`** contract via **`ContractGeneratorOperations.TryIssue`**. Body (**`schemaVersion` 1**): **`playerId`** (must match path `{id}` after normalize), **`templateId`**, **`seedBucket`**, optional **`zoneDifficultyBand`**. Response: **`issued`**, optional **`reasonCode`**, optional **`contract`** row projection. + +**Deny semantics:** **200** with **`issued: false`** + stable **`reasonCode`** (same codes as generator operations). **`active_contract_exists`** may echo the existing active **`contract`** row. + +```bash +curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/contracts/issue" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"playerId":"dev-local-1","templateId":"prototype_contract_clear_combat_pocket","seedBucket":"2026-06-28"}' +``` + +Bruno: `bruno/neon-sprawl-server/contracts/`. Plan: [NEO-151 implementation plan](../../docs/plans/NEO-151-implementation-plan.md). + +## Per-player contract list HTTP (NEO-151) + +**`GET /game/players/{id}/contracts`** returns **`schemaVersion` 1**, **`playerId`**, and **`contracts`**: the player's active instance (if any) plus up to **10** recent **completed** rows (**`issuedAt`** desc). Each row includes **`templateId`**, **`templateDisplayName`**, **`status`** (`active` \| `completed`), **`encounterTemplateId`**, **`seedBucket`**, **`issuedAt`**, optional **`completedAt`**, and optional **`completionRewardSummary`** (item/skill XP/reputation grant lines from **`IRewardDeliveryStore`** when **`contract_completion`** delivery exists — same nested shape as quest progress NEO-129/NEO-140). + +```bash +curl -sS "http://localhost:5253/game/players/dev-local-1/contracts" +``` + +Unknown player ids return **404** (position gate). Bruno: `bruno/neon-sprawl-server/contracts/`. ## Contract completion operations (NEO-149) @@ -347,7 +370,7 @@ HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-1 **Prototype edge case:** issuing a contract after the bound encounter is already cleared leaves the instance **`active`** with no re-trigger in v1 — prototype flow is issue-then-clear. -HTTP contract GET/issue projections land in **NEO-151** (E7M4-08). Plan: [NEO-149 implementation plan](../../docs/plans/NEO-149-implementation-plan.md). +Plan: [NEO-149 implementation plan](../../docs/plans/NEO-149-implementation-plan.md). Contract HTTP GET/issue: [Per-player contract list HTTP (NEO-151)](#per-player-contract-list-http-neo-151). ## Contract outcome store (NEO-146)