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..077f022 --- /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.js"); + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); + const { + moveNearPrototypeNpc, + PULSE_COOLDOWN_MS, + MAX_PULSE_DEFEAT_ATTEMPTS, + sleep, + } = require("./scripts/prototype-npc-bruno-helper.js"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + const slotIndex = 3; + + async function defeatNpc(targetId) { + await moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders); + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId }, + jsonHeaders, + ); + await sleep(bru, PULSE_COOLDOWN_MS); + const castBody = { + schemaVersion: 1, + slotIndex, + abilityId: "prototype_pulse", + targetId, + }; + for (let i = 0; i < MAX_PULSE_DEFEAT_ATTEMPTS; i++) { + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + castBody, + jsonHeaders, + ); + const body = response.data; + if (!body?.accepted || !body?.combatResolution) { + throw new Error( + `cast ${i + 1} vs ${targetId} failed (${body?.reasonCode ?? "no_reason"}): ${JSON.stringify(body)}`, + ); + } + if (body.combatResolution.targetDefeated) { + return; + } + if (i < MAX_PULSE_DEFEAT_ATTEMPTS - 1) { + await sleep(bru, PULSE_COOLDOWN_MS); + } + } + throw new Error( + `did not defeat ${targetId} within ${MAX_PULSE_DEFEAT_ATTEMPTS} pulses`, + ); + } + + await resetAllContractInstances(bru); + await resetPrototypeCombatTargets(bru); + + const issueResponse = await axios.post( + `${baseUrl}/game/players/${playerId}/contracts/issue`, + { + schemaVersion: 1, + playerId, + templateId: "prototype_contract_clear_combat_pocket", + seedBucket: "2026-06-28-neo151-bruno-clear", + }, + jsonHeaders, + ); + if (issueResponse.status !== 200 || !issueResponse.data?.issued) { + throw new Error(`contract issue failed: ${JSON.stringify(issueResponse.data)}`); + } + bru.setVar("neo151ContractInstanceId", issueResponse.data.contract.contractInstanceId); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await defeatNpc("prototype_npc_melee"); + await defeatNpc("prototype_npc_ranged"); + await defeatNpc("prototype_npc_elite"); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/contracts + body: none + auth: none +} + +script:post-response { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const second = await axios.get(`${baseUrl}/game/players/${playerId}/contracts`); + bru.setVar("neo151SecondContractsGetJson", JSON.stringify(second.data)); +} + +tests { + test("completed contract row includes delivery summary", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + const instanceId = bru.getVar("neo151ContractInstanceId"); + const row = body.contracts.find((x) => x.contractInstanceId === instanceId); + expect(row).to.be.an("object"); + expect(row.status).to.equal("completed"); + expect(row.encounterTemplateId).to.equal("prototype_combat_pocket"); + expect(row.completedAt).to.be.a("string"); + expect(row.completionRewardSummary).to.eql({ + itemGrants: [{ itemId: "scrap_metal_bulk", quantity: 5 }], + skillXpGrants: [{ skillId: "salvage", amount: 15 }], + reputationGrants: [], + }); + }); + + test("second GET completionRewardSummary matches first GET", function () { + const first = res.getBody(); + const second = JSON.parse(bru.getVar("neo151SecondContractsGetJson")); + const instanceId = bru.getVar("neo151ContractInstanceId"); + const firstRow = first.contracts.find((x) => x.contractInstanceId === instanceId); + const secondRow = second.contracts.find((x) => x.contractInstanceId === instanceId); + expect(secondRow.completionRewardSummary).to.eql(firstRow.completionRewardSummary); + }); +} 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..64cb476 --- /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.js"); + await resetAllContractInstances(bru); +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/contracts + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); + expect(body.contracts).to.be.an("array"); + }); + + test("no contract instances by default", function () { + expect(res.getBody().contracts).to.eql([]); + }); +} 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..59e7672 --- /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.js"); + await resetAllContractInstances(bru); +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/contracts/issue + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "playerId": "{{playerId}}", + "templateId": "prototype_contract_clear_combat_pocket", + "seedBucket": "2026-06-28-neo151-bruno" + } +} + +script:post-response { + const body = res.getBody(); + if (body?.contract?.contractInstanceId) { + bru.setVar("neo151ContractInstanceId", body.contract.contractInstanceId); + } +} + +tests { + test("returns 200 with issued true", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.issued).to.equal(true); + expect(body.reasonCode).to.equal(undefined); + }); + + test("active contract row includes encounter objective id", function () { + const contract = res.getBody().contract; + expect(contract).to.be.an("object"); + expect(contract.status).to.equal("active"); + expect(contract.templateId).to.equal("prototype_contract_clear_combat_pocket"); + expect(contract.encounterTemplateId).to.equal("prototype_combat_pocket"); + expect(contract.contractInstanceId).to.be.a("string"); + expect(contract.completionRewardSummary).to.equal(undefined); + }); +} diff --git a/bruno/neon-sprawl-server/contracts/POST contract issue whitespace path 404.bru b/bruno/neon-sprawl-server/contracts/POST contract issue whitespace path 404.bru new file mode 100644 index 0000000..6e58051 --- /dev/null +++ b/bruno/neon-sprawl-server/contracts/POST contract issue whitespace path 404.bru @@ -0,0 +1,30 @@ +meta { + name: POST contract issue whitespace path 404 + type: http + seq: 4 +} + +docs { + NEO-151 review: whitespace-only path id returns 404 (position gate), matching quest accept and contract GET. +} + +post { + url: {{baseUrl}}/game/players/%20/contracts/issue + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "playerId": "{{playerId}}", + "templateId": "prototype_contract_clear_combat_pocket", + "seedBucket": "2026-06-28-neo151-bruno" + } +} + +tests { + test("returns 404 for whitespace path id", function () { + expect(res.getStatus()).to.equal(404); + }); +} 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/scripts/reset-contract-instances-helper.js b/bruno/neon-sprawl-server/scripts/reset-contract-instances-helper.js new file mode 100644 index 0000000..e3b68ee --- /dev/null +++ b/bruno/neon-sprawl-server/scripts/reset-contract-instances-helper.js @@ -0,0 +1,39 @@ +async function resetAllContractInstances(bru) { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + + // GET is capped (active + 10 completed); loop until no rows remain so older + // completed instances outside the first page are cleared too. + const maxPasses = 100; + for (let pass = 0; pass < maxPasses; pass += 1) { + const listResponse = await axios.get( + `${baseUrl}/game/players/${playerId}/contracts`, + jsonHeaders, + ); + if (listResponse.status !== 200) { + throw new Error(`contracts GET failed: ${listResponse.status}`); + } + + const ids = (listResponse.data.contracts || []).map((row) => row.contractInstanceId); + if (ids.length === 0) { + return; + } + + await axios.post( + `${baseUrl}/game/players/${playerId}/__dev/quest-fixture`, + { + schemaVersion: 1, + resetContractInstanceIds: ids, + }, + jsonHeaders, + ); + } + + throw new Error( + `resetAllContractInstances exceeded ${maxPasses} passes; contract list may still be non-empty`, + ); +} + +module.exports = { resetAllContractInstances }; 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 new file mode 100644 index 0000000..e2cf468 --- /dev/null +++ b/docs/plans/NEO-151-implementation-plan.md @@ -0,0 +1,205 @@ +# NEO-151 — E7M4-08: Contract issue POST + per-player contract GET + +**Linear:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) +**Branch:** `NEO-151-e7m4-08-contract-issue-post-per-player-contract-get` +**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-08** +**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md) +**Pattern:** [NEO-120-implementation-plan.md](NEO-120-implementation-plan.md) (`QuestAcceptApi` issue envelope); [NEO-129-implementation-plan.md](NEO-129-implementation-plan.md) (`completionRewardSummary` on GET rows); [NEO-147-implementation-plan.md](NEO-147-implementation-plan.md) (`ContractGeneratorOperations.TryIssue`); [NEO-149-implementation-plan.md](NEO-149-implementation-plan.md) (encounter clear → completion + delivery) +**Precursors:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) **`Done`** — `ContractCompletionOperations` + encounter wiring; [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) **`Done`** — issue-time economy validation (**on `main`**) +**Blocks:** [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152) (telemetry hook sites), [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) (Godot contract HUD) +**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract panel after this story lands + +## Goal + +Client-readable contract issuance and state without local math — **`POST …/contracts/issue`** returns an **`active`** instance projection; **`GET …/contracts`** lists the player's active contract (if any) plus recent completed rows with encounter objective id and delivery summary. + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|-------|----------|---------------------|--------| +| GET completed history depth | How many completed rows alongside active? | **Active (0–1) + up to 10 recent completed**, ordered by **`issuedAt` desc** | **Adopted** — user chose cap 10 | +| POST body shape | Full `contract-seed` vs path-only player id? | **Full seed fields + `schemaVersion` 1**; **`playerId` in body must match path `{id}`** (400 on mismatch); **`templateId` required** per schema + NEO-147 | **Adopted** — schema + NEO-147 precedent | +| Issue response envelope | Raw snapshot vs structured response? | **`{ issued, reasonCode, contract }`** mirroring quest accept **`{ accepted, reasonCode, quest }`** | **Adopted** — NEO-120 pattern | +| Grant summary DTOs | New contract types vs reuse quest summary? | **Reuse `QuestCompletionRewardSummaryJson`** and grant line types — same `IRewardDeliveryStore` snapshot shape | **Adopted** — NEO-129/NEO-140 | +| Store read for GET | Extend `IContractInstanceStore` vs ad-hoc SQL in API? | **Add `ListForPlayer(playerId, maxCompletedRows)`** on interface + both store impls | **Adopted** — store boundary precedent (NEO-146) | +| Issue deny HTTP status | 400 vs 200 with `issued: false`? | **200 OK** with **`issued: false`** + **`reasonCode`** (and optional **`contract`** snapshot on `active_contract_exists`) | **Adopted** — quest accept precedent | +| Bruno scope | Minimal vs full issue → clear → GET loop? | **`bruno/neon-sprawl-server/contracts/`** — issue, GET default, GET after encounter clear (reuse ability-cast / encounter clear spine) | **Adopted** — backlog in-scope | +| Telemetry / Godot | In scope? | **Out of scope** — NEO-152 comment hooks; NEO-153 client HUD | **Adopted** — backlog | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- **`POST /game/players/{id}/contracts/issue`** — `ContractIssueApi` + request/response DTOs; delegates to **`ContractGeneratorOperations.TryIssue`** with full DI registries (encounter, item, skill, faction per NEO-150 handoff). +- **`GET /game/players/{id}/contracts`** — `ContractListApi` + list DTOs; active row + up to **10** recent completed instances with template summary, **`encounterTemplateId`**, **`completionRewardSummary`** when delivered. +- **`IContractInstanceStore.ListForPlayer`** — read path for completed history (in-memory + Postgres). +- Integration tests (`ContractIssueApiTests` / `ContractListApiTests` or combined `ContractApiTests`). +- Bruno **`bruno/neon-sprawl-server/contracts/`** — issue → encounter clear → GET loop; update contract-stores reset bru cross-link. +- `server/README.md` contract HTTP sections; E7.M4 module anchor. + +**Out of scope (from Linear + backlog):** + +- Godot parse/HUD (**NEO-153** / E7M4-10). +- Telemetry comment hooks (**NEO-152** / E7M4-09). +- Optional **`templateId`** auto-select over HTTP (ops layer supports null; HTTP requires explicit id per schema). +- Live E4.M1 zone read for **`zoneDifficultyBand`** (POST override only until E4.M1). + +**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — server HTTP must land in this story first. + +## Acceptance criteria checklist + +- [x] POST issue returns **`active`** instance JSON. +- [x] GET lists active contract with encounter objective id. +- [x] GET completed row includes delivery summary after encounter clear. + +## Implementation reconciliation (shipped) + +- **`IContractInstanceStore.ListForPlayer`** — active first + up to 10 completed rows (`issuedAt` desc); in-memory + Postgres impls. +- **`ContractIssueApi`** — `POST …/contracts/issue` delegates to **`ContractGeneratorOperations.TryIssue`** with full registry DI. +- **`ContractListApi`** — `GET …/contracts` with **`completionRewardSummary`** via shared **`QuestProgressApi`** delivery mapper. +- **Tests:** 5 store list unit tests + 14 HTTP integration tests (incl. empty seed-field `[Theory]`) + Postgres **`ListForPlayer`** persistence test; **933** tests green. +- **Bruno:** `bruno/neon-sprawl-server/contracts/` — default GET, issue, encounter-clear capstone. +- **Docs:** `server/README.md`, `E7_M4_ContractMissionGenerator.md`, alignment register. + +## Technical approach + +### 1. Store read — `IContractInstanceStore.ListForPlayer` + +```csharp +IReadOnlyList ListForPlayer(string playerId, int maxCompletedRows); +``` + +**Semantics:** + +- Normalize **`playerId`**; return empty list when player not writable / unknown to store. +- If an **active** row exists, include it first (at most one). +- Append up to **`maxCompletedRows`** **completed** instances for that player, **`issuedAt` descending** (tie-break **`contractInstanceId`** ordinal). +- Prototype constant **`ContractListApi.MaxCompletedRows = 10`** (kickoff decision). + +**Postgres:** `SELECT … WHERE player_id = @pid AND status = 'completed' ORDER BY issued_at DESC, contract_instance_id ASC LIMIT @n` plus active query on the same connection. + +**In-memory:** scan **`byInstanceId`** for player-owned completed rows, sort, take N, prepend active. + +Unit tests in **`InMemoryContractInstanceStoreTests`**: empty player, active only, completed only, active + multiple completed ordering, cap respected. + +### 2. DTOs (`Game/Contracts/ContractApiDtos.cs`) + +| Type | Role | +|------|------| +| **`ContractIssueRequest`** | `schemaVersion` **1**, `playerId`, `templateId`, `seedBucket`, optional `zoneDifficultyBand` — mirrors [`contract-seed.schema.json`](../../content/schemas/contract-seed.schema.json) + version gate | +| **`ContractIssueResponse`** | `schemaVersion`, `issued`, optional `reasonCode`, optional **`ContractInstanceRowJson`** | +| **`ContractListResponse`** | `schemaVersion`, `playerId`, **`contracts`** array | +| **`ContractInstanceRowJson`** | `contractInstanceId`, `templateId`, `templateDisplayName`, `status` (`active` \| `completed`), `encounterTemplateId`, `seedBucket`, `issuedAt`, optional `completedAt`, optional **`completionRewardSummary`** (`QuestCompletionRewardSummaryJson`) | + +**Row mapping (`ContractListApi.MapInstanceRow`):** + +1. Load template via **`IContractTemplateRegistry.TryGetDefinition`** for **`templateDisplayName`** + **`encounterTemplateId`** fallback. +2. For **`completed`** status, **`IRewardDeliveryStore.TryGet(playerId, RewardDeliverySourceKinds.ContractCompletion, contractInstanceId, …)`** → map grants via same helpers as **`QuestProgressApi.MapCompletionRewardSummary`** (extract shared mapper or duplicate minimally — prefer internal reuse on **`QuestProgressApi`** mappers if accessible, else parallel private helpers in contract API). + +### 3. `ContractIssueApi` + +**Route:** `POST /game/players/{id}/contracts/issue` + +**Handler flow:** + +1. **404** when path `{id}` is whitespace-only or player unknown (**`IPositionStateStore`** gate — same as quest progress / accept). +2. **400** when body null, **`schemaVersion`** mismatch, empty **`templateId`** / **`seedBucket`** / body **`playerId`**, or body **`playerId`** ≠ trimmed path `{id}` after normalize. +3. Call **`ContractGeneratorOperations.TryIssue`** with path player id, body fields, and injected registries/stores (**mirror `ContractGeneratorOperationsIntegrationTests`** DI wiring). +4. **200** **`ContractIssueResponse`** via **`MapIssueResponse`** — on success map **`ContractInstanceRowJson`** from result snapshot + template registry; on deny include **`reasonCode`** and optional snapshot row (e.g. **`active_contract_exists`**). + +Register in **`Program.cs`** alongside quest routes: **`app.MapContractIssueApi()`**. + +### 4. `ContractListApi` + +**Route:** `GET /game/players/{id}/contracts` + +**Handler flow:** + +1. **404** unknown player (position gate). +2. **`instanceStore.ListForPlayer(trimmedId, ContractListApi.MaxCompletedRows)`**. +3. Map each snapshot to **`ContractInstanceRowJson`**. +4. **200** **`ContractListResponse`** — **`contracts`** may be empty array. + +### 5. Integration tests (`ContractApiTests`) + +| Test | Covers | +|------|--------| +| POST issue 404 unknown player | Position gate | +| POST issue 400 schema / playerId mismatch | Request validation | +| POST issue 200 happy path | **`issued: true`**, **`active`**, **`encounterTemplateId: prototype_combat_pocket`**, deterministic **`contractInstanceId`** | +| POST issue 200 deny second active | **`issued: false`**, **`active_contract_exists`**, optional active row echo | +| GET 404 unknown player | Position gate | +| GET empty list | Known player, no instances | +| GET active after issue | Active row with encounter id, no summary | +| GET completed after encounter clear | Issue → **`ContractCompletionOperations.TryCompleteOnEncounterClear`** (or ability-cast integration path) → GET asserts **`completed`**, **`completionRewardSummary`** with prototype bundle lines | +| GET idempotent replay | Second GET same summary (mirror NEO-129) | + +Optional **`RequirePostgresFact`**: issue + list round-trip on Postgres store (mirror NEO-146 persistence tests). + +### 6. Bruno (`bruno/neon-sprawl-server/contracts/`) + +| File | Purpose | +|------|---------| +| `Issue prototype contract.bru` | POST issue with frozen template + seed bucket | +| `Get contracts default.bru` | GET empty or active-only baseline | +| `Get contracts after encounter clear.bru` | Full spine: issue → clear **`prototype_combat_pocket`** → GET asserts **`completionRewardSummary`** | + +Update **`contract-stores/Reset contract instance via quest fixture.bru`** docs to reference real instance id from issue bru. + +### 7. Docs + +- **`server/README.md`** — contract issue GET/POST routes, JSON samples, reason codes (link generator/completion sections). +- **`E7_M4_ContractMissionGenerator.md`** — E7M4-08 HTTP landed note. +- **`documentation_and_implementation_alignment.md`** — E7.M4 row after implementation. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-151-implementation-plan.md` | This plan | +| `server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs` | Issue/list request/response + row JSON types | +| `server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs` | `POST …/contracts/issue` handler + mapper | +| `server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs` | `GET …/contracts` handler + row mapper | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs` | AAA HTTP integration tests | +| `bruno/neon-sprawl-server/contracts/Issue prototype contract.bru` | Bruno issue spine | +| `bruno/neon-sprawl-server/contracts/Get contracts default.bru` | Bruno GET baseline | +| `bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru` | Bruno issue → clear → GET capstone | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs` | Add **`ListForPlayer`** for completed history reads | +| `server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs` | Implement **`ListForPlayer`** | +| `server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs` | Implement **`ListForPlayer`** SQL | +| `server/NeonSprawl.Server/Program.cs` | Register **`MapContractIssueApi`** + **`MapContractListApi`** | +| `server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs` | Unit tests for **`ListForPlayer`** ordering + cap | +| `server/README.md` | Document contract issue/list HTTP routes + samples | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-151 HTTP anchor | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M4 alignment note (post-implementation) | +| `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru` | Cross-link contracts bru folder | + +## Tests + +| File | What it covers | +|------|----------------| +| `InMemoryContractInstanceStoreTests` | **`ListForPlayer`**: empty, active-only, completed cap, ordering | +| `ContractApiTests` | POST issue happy/deny paths; GET empty/active/completed + **`completionRewardSummary`**; 404/400 gates | +| `ContractGeneratorOperationsIntegrationTests` (existing) | Unchanged — orchestrator DI baseline | +| CI | `dotnet test` green; Bruno manual against dev server | + +Manual Godot QA: **none** (server HTTP; capstone **NEO-154**). Bruno: **`contracts/`** folder per plan. + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **`TryIssue` registry wiring in HTTP handler** | Pass same four registries as **`ContractGeneratorOperationsIntegrationTests`** — verified at kickoff (NEO-150 handoff) | `adopted` | +| Duplicate grant-summary mapper vs quest API | **`QuestProgressApi.MapCompletionRewardSummary`** extended with `sourceKind` + `sourceId`; contract API reuses it | `adopted` | +| GET payload size with 10 completed rows | Accept for prototype; client (**NEO-153**) reads active row first | `adopted` | +| **`contract-seed.schema.json` lacks `schemaVersion`** | HTTP DTO adds **`schemaVersion`** alongside seed fields (quest/inventory precedent); schema file unchanged unless CI needs `$ref` later | `adopted` | +| Postgres **`ListForPlayer`** index | Existing **`player_id`** + status filter sufficient for prototype volume; no new migration | `adopted` | + +## Client counterpart + +[NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — **`contract_client.gd`**, issue binding, HUD labels; blocked until this story merges. Surfaces deny codes including **`economy_cap_exceeded`** and **`invalid_reward_bundle`** from NEO-150. diff --git a/docs/reviews/2026-06-28-NEO-151.md b/docs/reviews/2026-06-28-NEO-151.md new file mode 100644 index 0000000..7e4895e --- /dev/null +++ b/docs/reviews/2026-06-28-NEO-151.md @@ -0,0 +1,68 @@ +# Code review — NEO-151 contract issue POST + per-player contract GET + +**Date:** 2026-06-28 +**Scope:** Branch `NEO-151-e7m4-08-contract-issue-post-per-player-contract-get` — commits `9994aee`..`c0252ee` (2 commits) +**Base:** `main` +**Issue:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) — E7M4-08 contract issue POST + per-player contract GET + +## Verdict + +**Approve with nits** + +## Summary + +This branch lands client-readable contract HTTP: **`POST …/contracts/issue`** delegates to **`ContractGeneratorOperations.TryIssue`** with the quest-accept-style **`{ issued, reasonCode, contract }`** envelope; **`GET …/contracts`** lists the active instance (if any) plus up to 10 completed rows with **`encounterTemplateId`** and **`completionRewardSummary`** on completed rows via shared **`QuestProgressApi.MapCompletionRewardSummary`**. + +Store work adds **`IContractInstanceStore.ListForPlayer`** to both in-memory and Postgres implementations with active-first ordering and **`issuedAt` desc** cap semantics. Integration coverage is strong (10 HTTP tests + 5 store list tests); Bruno **`contracts/`** folder includes a full issue → encounter clear → GET capstone. Full suite passes (**927** tests). Risk is low for prototype scope; residual gaps are HTTP edge-case consistency and optional Postgres list persistence coverage. + +## Documentation checked + +| Document | Result | +|----------|--------| +| `docs/plans/NEO-151-implementation-plan.md` | **Matches** — AC checklist complete; shipped reconciliation matches diff; kickoff decisions (cap 10, issue envelope, reward summary reuse, store boundary) reflected in code | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-151 HTTP anchor + README links added | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row extended with E7M4-08 / NEO-151 | +| `docs/decomposition/modules/module_dependency_register.md` | **Not re-read in diff** — no register change required; E7.M4 already tracked | +| `server/README.md` | **Matches** — contract issue/list sections, **`ListForPlayer`** semantics, curl samples | +| `docs/plans/E7M4-pre-production-backlog.md` | **Not re-read in diff** — plan cites E7M4-08; scope satisfied | +| Client counterpart (NEO-153) | **N/A** — correctly out of scope; plan does not claim player-visible slice complete | + +Register/tracking table updates are included in this branch. + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Align empty path `{id}` on POST with sibling player APIs** — `ContractIssueApi` returns **400** when the path id normalizes to empty (whitespace-only), while `ContractListApi`, `QuestAcceptApi`, and most other player routes return **404** for `trimmedId.Length == 0`. Recommend matching the **404** position-gate pattern for consistency.~~ **Done.** — position gate runs first; whitespace-only path returns **404**. + +2. ~~**Add POST 400 tests for missing seed fields** — Handler rejects empty **`templateId`** / **`seedBucket`** (lines 32–36 in `ContractIssueApi.cs`), but `ContractApiTests` only covers schema version and **`playerId`** mismatch. One test each (or a `[Theory]`) would lock the validation gate.~~ **Done.** — `[Theory]` for empty/whitespace **`templateId`** and **`seedBucket`**; whitespace path **404** test added. + +3. ~~**Optional Postgres `ListForPlayer` round-trip** — Plan marks this optional; if CI Postgres is stable, a `[RequirePostgresFact]` mirroring NEO-146 store tests would guard SQL ordering/cap behavior separately from in-memory parity.~~ **Done.** — `ListForPlayer_ShouldPrependActiveAndCapCompletedRows_OnPostgres` in `ContractInstancePersistenceIntegrationTests`. + +## Nits + +- ~~Nit: **`PostgresContractInstanceStore.ListForPlayer`** opens a connection then calls **`TryGetActiveForPlayer`**, which opens a second connection. Fine at prototype volume; could inline the active SELECT on the existing connection later.~~ **Done.** — active SELECT inlined on the list connection. +- ~~Nit: **`GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear`** invokes **`TryCompleteOnEncounterClear`** under **`// Act`** before the GET — consider moving completion into **`// Arrange`** so Act is HTTP-only (AAA clarity).~~ **Done.** +- ~~Nit: Plan §1 Postgres snippet mentions **`completed_at DESC`** but kickoff + README + implementation use **`issuedAt` desc** — update the plan technical section on merge to remove the stale SQL note (implementation is correct).~~ **Done.** + +## Bugbot (PR #193) + +| Severity | Location | Finding | Status | +|----------|----------|---------|--------| +| Low | `scripts/reset-contract-instances-helper.js` | Reset helper only cleared one capped GET page (active + 10 completed); older rows could survive pre-request cleanup | **Done.** — loop GET → reset until list empty (max 100 passes) | +| Low | `contracts/scripts/` helper path | Bruno resolves `./scripts/` from collection root, not per-folder — CI module not found | **Done.** — moved helper to `neon-sprawl-server/scripts/`; `.js` suffix on requires | +| Medium | `PostgresContractInstanceStore.ListForPlayer` | Active + completed read in two queries without snapshot — duplicate row if status flips mid-read | **Done.** — single CTE query for atomic list snapshot | + +## Verification + +```bash +dotnet test NeonSprawl.sln +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractApiTests" +``` + +Manual (dev server): + +- Run Bruno folder `bruno/neon-sprawl-server/contracts/` — especially **Get contracts after encounter clear** capstone. +- Smoke **`POST …/contracts/issue`** deny path: second issue while active → **`issued: false`**, **`active_contract_exists`**. 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..63c2993 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs @@ -0,0 +1,406 @@ +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public sealed class ContractApiTests +{ + private const string PlayerId = "dev-local-1"; + private const string TemplateId = "prototype_contract_clear_combat_pocket"; + private const string EncounterId = "prototype_combat_pocket"; + private const string SeedBucket = "2026-06-28-neo151"; + private const string DisplayName = "Clear Combat Pocket (Repeat)"; + private static readonly DateTimeOffset CompletedAt = new(2026, 6, 28, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + "/game/players/missing-player/contracts/issue", + JsonContent.Create(CreateIssueRequest("missing-player"))); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + "/game/players/%20/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("", SeedBucket)] + [InlineData(" ", SeedBucket)] + [InlineData(TemplateId, "")] + [InlineData(TemplateId, " ")] + public async Task PostContractIssue_ShouldReturnBadRequest_WhenRequiredSeedFieldsEmpty( + string templateId, + string seedBucket) + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create( + new ContractIssueRequest + { + SchemaVersion = ContractIssueRequest.CurrentSchemaVersion, + PlayerId = PlayerId, + TemplateId = templateId, + SeedBucket = seedBucket, + })); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnBadRequest_WhenPlayerIdMismatch() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest("other-player"))); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnBadRequest_WhenSchemaVersionMismatch() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create( + new ContractIssueRequest + { + SchemaVersion = 99, + PlayerId = PlayerId, + TemplateId = TemplateId, + SeedBucket = SeedBucket, + })); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostContractIssue_ShouldReturnIssuedTrue_WhenPrototypeTemplateEligible() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId( + PlayerId, + TemplateId, + SeedBucket); + + // Act + var response = await client.PostAsync( + $"/game/players/{PlayerId}/contracts/issue", + JsonContent.Create(CreateIssueRequest(PlayerId))); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + 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; + var completed = ContractCompletionOperations.TryCompleteOnEncounterClear( + PlayerId, + EncounterId, + deps.InstanceStore, + deps.OutcomeStore, + deps.TemplateRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, + deps.TimeProvider); + Assert.True(completed.Success); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/contracts"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + 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/ContractInstancePersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs index 008f1a0..551c721 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractInstancePersistenceIntegrationTests.cs @@ -113,6 +113,55 @@ public sealed class ContractInstancePersistenceIntegrationTests(PostgresIntegrat Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId); } + [RequirePostgresFact] + public async Task ListForPlayer_ShouldPrependActiveAndCapCompletedRows_OnPostgres() + { + // Arrange + await ResetContractInstanceTableAsync(); + using var scope = Factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + for (var i = 0; i < 12; i++) + { + var instanceId = $"prototype_contract_instance_pg_done_{i:D2}"; + Assert.True(store.TryCreateActive( + PlayerId, + instanceId, + TemplateId, + SeedBucket, + IssuedAt.AddHours(i), + out _)); + Assert.True(store.TryMarkComplete( + PlayerId, + instanceId, + CompletedAt.AddHours(i), + out _)); + } + + const string activeId = "prototype_contract_instance_pg_active"; + Assert.True(store.TryCreateActive( + PlayerId, + activeId, + TemplateId, + SeedBucket, + IssuedAt.AddDays(3), + out _)); + + // Act + var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10); + + // Assert + Assert.Equal(11, rows.Count); + Assert.Equal(activeId, rows[0].ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, rows[0].Status); + Assert.All(rows.Skip(1), row => Assert.Equal(ContractInstanceStatus.Completed, row.Status)); + Assert.DoesNotContain( + rows.Skip(1), + row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_pg_done_00", StringComparison.Ordinal)); + Assert.DoesNotContain( + rows.Skip(1), + row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_pg_done_01", StringComparison.Ordinal)); + } + [RequirePostgresFact] public async Task CreateComplete_ShouldPersistAcrossNewFactory() { 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..502e6b3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs @@ -0,0 +1,96 @@ +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Contracts; + +/// 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) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + if (body is null || body.SchemaVersion != ContractIssueRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(body.TemplateId); + var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(body.SeedBucket); + var normalizedBodyPlayerId = ContractInstanceIds.NormalizePlayerId(body.PlayerId); + if (normalizedTemplateId.Length == 0 || + normalizedSeedBucket.Length == 0 || + normalizedBodyPlayerId.Length == 0) + { + return Results.BadRequest(); + } + + var normalizedPathPlayerId = ContractInstanceIds.NormalizePlayerId(trimmedId); + if (!string.Equals(normalizedBodyPlayerId, normalizedPathPlayerId, StringComparison.Ordinal)) + { + return Results.BadRequest(); + } + + var result = ContractGeneratorOperations.TryIssue( + trimmedId, + normalizedTemplateId, + normalizedSeedBucket, + body.ZoneDifficultyBand, + templateRegistry, + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry, + instanceStore, + standingStore, + timeProvider); + + return Results.Json( + MapIssueResponse(trimmedId, result, templateRegistry, deliveryStore)); + }); + + return app; + } + + internal static ContractIssueResponse MapIssueResponse( + string playerId, + ContractIssueOperationResult result, + IContractTemplateRegistry templateRegistry, + IRewardDeliveryStore deliveryStore) + { + ContractInstanceRowJson? contract = null; + if (result.Snapshot is { } snapshot) + { + contract = ContractListApi.MapInstanceRow( + playerId, + snapshot, + templateRegistry, + deliveryStore, + result.EncounterTemplateId); + } + + return new ContractIssueResponse + { + Issued = result.Success, + ReasonCode = result.ReasonCode, + Contract = contract, + }; + } +} 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..87b7890 100644 --- a/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs @@ -314,6 +314,60 @@ 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 []; + } + + // Single statement snapshot — avoids active+completed duplicate rows if status flips mid-read. + using var cmd = new Npgsql.NpgsqlCommand( + """ + WITH active_row AS ( + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 0 AS section + FROM contract_instance + WHERE player_id = @pid AND status = 'active' + LIMIT 1 + ), + completed_rows AS ( + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 1 AS section + FROM contract_instance + WHERE player_id = @pid AND status = 'completed' + ORDER BY issued_at DESC, contract_instance_id ASC + LIMIT @limit + ) + SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at + FROM ( + SELECT * FROM active_row + UNION ALL + SELECT * FROM completed_rows + ) AS listed + ORDER BY section, issued_at DESC, contract_instance_id ASC; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + cmd.Parameters.AddWithValue("limit", maxCompletedRows); + + var result = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + result.Add(ReadSnapshot(reader)); + } + + 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..b91f723 100644 --- a/server/README.md +++ b/server/README.md @@ -152,7 +152,7 @@ Plan: [NEO-141 implementation plan](../../docs/plans/NEO-141-implementation-plan **400** for bad schema; **404** when disabled, player unknown, unknown quest/faction id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` uses **`resetQuestIds`**, **`resetFactionIds`**, and **`completedQuestIds`**; order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**. -**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**. +**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). **`scripts/reset-contract-instances-helper.js`** clears all contract instances via capped GET loop + quest-fixture reset (NEO-151). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**. ## Resource-node catalog (`content/resource-nodes`, NEO-58) @@ -281,6 +281,7 @@ Per-player contract runtime state lives in **`IContractInstanceStore`**, keyed b - **`TryCreateActive`** — first active for player returns `true`; denies when another active exists, duplicate instance id, or player not writable. - **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**. - **`TryClearInstance`** — dev fixture only; removes one instance row. +- **`ListForPlayer`** — active row first (when present), then up to N recent **completed** rows ordered by **`issuedAt`** descending (NEO-151 HTTP read path). Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation and selection run in **`ContractGeneratorOperations`** (NEO-147). @@ -316,7 +317,29 @@ Completed instances are immutable (no status regression). Player, template, and **Result:** **`ContractIssueOperationResult`** — `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, **`encounterTemplateId`** from the selected template. -HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md), [NEO-150 implementation plan](../../docs/plans/NEO-150-implementation-plan.md). +## Contract issue HTTP (NEO-151) + +**`POST /game/players/{id}/contracts/issue`** issues an **`active`** contract via **`ContractGeneratorOperations.TryIssue`**. Body (**`schemaVersion` 1**): **`playerId`** (must match path `{id}` after normalize), **`templateId`**, **`seedBucket`**, optional **`zoneDifficultyBand`**. Response: **`issued`**, optional **`reasonCode`**, optional **`contract`** row projection. + +**Deny semantics:** **200** with **`issued: false`** + stable **`reasonCode`** (same codes as generator operations). **`active_contract_exists`** may echo the existing active **`contract`** row. + +```bash +curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/contracts/issue" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"playerId":"dev-local-1","templateId":"prototype_contract_clear_combat_pocket","seedBucket":"2026-06-28"}' +``` + +Bruno: `bruno/neon-sprawl-server/contracts/`. Plan: [NEO-151 implementation plan](../../docs/plans/NEO-151-implementation-plan.md). + +## Per-player contract list HTTP (NEO-151) + +**`GET /game/players/{id}/contracts`** returns **`schemaVersion` 1**, **`playerId`**, and **`contracts`**: the player's active instance (if any) plus up to **10** recent **completed** rows (**`issuedAt`** desc). Each row includes **`templateId`**, **`templateDisplayName`**, **`status`** (`active` \| `completed`), **`encounterTemplateId`**, **`seedBucket`**, **`issuedAt`**, optional **`completedAt`**, and optional **`completionRewardSummary`** (item/skill XP/reputation grant lines from **`IRewardDeliveryStore`** when **`contract_completion`** delivery exists — same nested shape as quest progress NEO-129/NEO-140). + +```bash +curl -sS "http://localhost:5253/game/players/dev-local-1/contracts" +``` + +Unknown player ids return **404** (position gate). Bruno: `bruno/neon-sprawl-server/contracts/`. ## Contract completion operations (NEO-149) @@ -347,7 +370,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)