Merge pull request #193 from ViPro-Technologies/NEO-151-e7m4-08-contract-issue-post-per-player-contract-get
NEO-151: Contract issue POST + per-player contract GETmain
commit
060040e87a
|
|
@ -21,7 +21,7 @@ body:json {
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-149: quest-fixture accepts resetContractInstanceIds — clears contract instance, contract_completion delivery row, and outcome audit for each id (idempotent when absent).
|
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 {
|
tests {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
meta {
|
||||||
|
name: GET contracts after encounter clear
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-151 AC: issue prototype contract, clear prototype_combat_pocket via three-NPC defeat spine, GET contracts shows completed row with completionRewardSummary.
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const axios = require("axios");
|
||||||
|
const { resetAllContractInstances } = require("./scripts/reset-contract-instances-helper.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
meta {
|
||||||
|
name: contracts
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-151: contract issue POST + per-player contract GET.
|
||||||
|
Cross-link: bruno/neon-sprawl-server/encounter-progress/ for encounter clear spine.
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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 };
|
||||||
|
|
@ -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 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
|
## Source anchors
|
||||||
|
|
||||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.
|
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Semantics:**
|
||||||
|
|
||||||
|
- Normalize **`playerId`**; return empty list when player not writable / unknown to store.
|
||||||
|
- If an **active** row exists, include it first (at most one).
|
||||||
|
- Append up to **`maxCompletedRows`** **completed** instances for that player, **`issuedAt` descending** (tie-break **`contractInstanceId`** ordinal).
|
||||||
|
- Prototype constant **`ContractListApi.MaxCompletedRows = 10`** (kickoff decision).
|
||||||
|
|
||||||
|
**Postgres:** `SELECT … WHERE player_id = @pid AND status = 'completed' ORDER BY issued_at DESC, contract_instance_id ASC LIMIT @n` plus active query on the same connection.
|
||||||
|
|
||||||
|
**In-memory:** scan **`byInstanceId`** for player-owned completed rows, sort, take N, prepend active.
|
||||||
|
|
||||||
|
Unit tests in **`InMemoryContractInstanceStoreTests`**: empty player, active only, completed only, active + multiple completed ordering, cap respected.
|
||||||
|
|
||||||
|
### 2. DTOs (`Game/Contracts/ContractApiDtos.cs`)
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| **`ContractIssueRequest`** | `schemaVersion` **1**, `playerId`, `templateId`, `seedBucket`, optional `zoneDifficultyBand` — mirrors [`contract-seed.schema.json`](../../content/schemas/contract-seed.schema.json) + version gate |
|
||||||
|
| **`ContractIssueResponse`** | `schemaVersion`, `issued`, optional `reasonCode`, optional **`ContractInstanceRowJson`** |
|
||||||
|
| **`ContractListResponse`** | `schemaVersion`, `playerId`, **`contracts`** array |
|
||||||
|
| **`ContractInstanceRowJson`** | `contractInstanceId`, `templateId`, `templateDisplayName`, `status` (`active` \| `completed`), `encounterTemplateId`, `seedBucket`, `issuedAt`, optional `completedAt`, optional **`completionRewardSummary`** (`QuestCompletionRewardSummaryJson`) |
|
||||||
|
|
||||||
|
**Row mapping (`ContractListApi.MapInstanceRow`):**
|
||||||
|
|
||||||
|
1. Load template via **`IContractTemplateRegistry.TryGetDefinition`** for **`templateDisplayName`** + **`encounterTemplateId`** fallback.
|
||||||
|
2. For **`completed`** status, **`IRewardDeliveryStore.TryGet(playerId, RewardDeliverySourceKinds.ContractCompletion, contractInstanceId, …)`** → map grants via same helpers as **`QuestProgressApi.MapCompletionRewardSummary`** (extract shared mapper or duplicate minimally — prefer internal reuse on **`QuestProgressApi`** mappers if accessible, else parallel private helpers in contract API).
|
||||||
|
|
||||||
|
### 3. `ContractIssueApi`
|
||||||
|
|
||||||
|
**Route:** `POST /game/players/{id}/contracts/issue`
|
||||||
|
|
||||||
|
**Handler flow:**
|
||||||
|
|
||||||
|
1. **404** when path `{id}` is whitespace-only or player unknown (**`IPositionStateStore`** gate — same as quest progress / accept).
|
||||||
|
2. **400** when body null, **`schemaVersion`** mismatch, empty **`templateId`** / **`seedBucket`** / body **`playerId`**, or body **`playerId`** ≠ trimmed path `{id}` after normalize.
|
||||||
|
3. Call **`ContractGeneratorOperations.TryIssue`** with path player id, body fields, and injected registries/stores (**mirror `ContractGeneratorOperationsIntegrationTests`** DI wiring).
|
||||||
|
4. **200** **`ContractIssueResponse`** via **`MapIssueResponse`** — on success map **`ContractInstanceRowJson`** from result snapshot + template registry; on deny include **`reasonCode`** and optional snapshot row (e.g. **`active_contract_exists`**).
|
||||||
|
|
||||||
|
Register in **`Program.cs`** alongside quest routes: **`app.MapContractIssueApi()`**.
|
||||||
|
|
||||||
|
### 4. `ContractListApi`
|
||||||
|
|
||||||
|
**Route:** `GET /game/players/{id}/contracts`
|
||||||
|
|
||||||
|
**Handler flow:**
|
||||||
|
|
||||||
|
1. **404** unknown player (position gate).
|
||||||
|
2. **`instanceStore.ListForPlayer(trimmedId, ContractListApi.MaxCompletedRows)`**.
|
||||||
|
3. Map each snapshot to **`ContractInstanceRowJson`**.
|
||||||
|
4. **200** **`ContractListResponse`** — **`contracts`** may be empty array.
|
||||||
|
|
||||||
|
### 5. Integration tests (`ContractApiTests`)
|
||||||
|
|
||||||
|
| Test | Covers |
|
||||||
|
|------|--------|
|
||||||
|
| POST issue 404 unknown player | Position gate |
|
||||||
|
| POST issue 400 schema / playerId mismatch | Request validation |
|
||||||
|
| POST issue 200 happy path | **`issued: true`**, **`active`**, **`encounterTemplateId: prototype_combat_pocket`**, deterministic **`contractInstanceId`** |
|
||||||
|
| POST issue 200 deny second active | **`issued: false`**, **`active_contract_exists`**, optional active row echo |
|
||||||
|
| GET 404 unknown player | Position gate |
|
||||||
|
| GET empty list | Known player, no instances |
|
||||||
|
| GET active after issue | Active row with encounter id, no summary |
|
||||||
|
| GET completed after encounter clear | Issue → **`ContractCompletionOperations.TryCompleteOnEncounterClear`** (or ability-cast integration path) → GET asserts **`completed`**, **`completionRewardSummary`** with prototype bundle lines |
|
||||||
|
| GET idempotent replay | Second GET same summary (mirror NEO-129) |
|
||||||
|
|
||||||
|
Optional **`RequirePostgresFact`**: issue + list round-trip on Postgres store (mirror NEO-146 persistence tests).
|
||||||
|
|
||||||
|
### 6. Bruno (`bruno/neon-sprawl-server/contracts/`)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `Issue prototype contract.bru` | POST issue with frozen template + seed bucket |
|
||||||
|
| `Get contracts default.bru` | GET empty or active-only baseline |
|
||||||
|
| `Get contracts after encounter clear.bru` | Full spine: issue → clear **`prototype_combat_pocket`** → GET asserts **`completionRewardSummary`** |
|
||||||
|
|
||||||
|
Update **`contract-stores/Reset contract instance via quest fixture.bru`** docs to reference real instance id from issue bru.
|
||||||
|
|
||||||
|
### 7. Docs
|
||||||
|
|
||||||
|
- **`server/README.md`** — contract issue GET/POST routes, JSON samples, reason codes (link generator/completion sections).
|
||||||
|
- **`E7_M4_ContractMissionGenerator.md`** — E7M4-08 HTTP landed note.
|
||||||
|
- **`documentation_and_implementation_alignment.md`** — E7.M4 row after implementation.
|
||||||
|
|
||||||
|
## Files to add
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `docs/plans/NEO-151-implementation-plan.md` | This plan |
|
||||||
|
| `server/NeonSprawl.Server/Game/Contracts/ContractApiDtos.cs` | Issue/list request/response + row JSON types |
|
||||||
|
| `server/NeonSprawl.Server/Game/Contracts/ContractIssueApi.cs` | `POST …/contracts/issue` handler + mapper |
|
||||||
|
| `server/NeonSprawl.Server/Game/Contracts/ContractListApi.cs` | `GET …/contracts` handler + row mapper |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractApiTests.cs` | AAA HTTP integration tests |
|
||||||
|
| `bruno/neon-sprawl-server/contracts/Issue prototype contract.bru` | Bruno issue spine |
|
||||||
|
| `bruno/neon-sprawl-server/contracts/Get contracts default.bru` | Bruno GET baseline |
|
||||||
|
| `bruno/neon-sprawl-server/contracts/Get contracts after encounter clear.bru` | Bruno issue → clear → GET capstone |
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| Path | Rationale |
|
||||||
|
|------|-----------|
|
||||||
|
| `server/NeonSprawl.Server/Game/Contracts/IContractInstanceStore.cs` | Add **`ListForPlayer`** for completed history reads |
|
||||||
|
| `server/NeonSprawl.Server/Game/Contracts/InMemoryContractInstanceStore.cs` | Implement **`ListForPlayer`** |
|
||||||
|
| `server/NeonSprawl.Server/Game/Contracts/PostgresContractInstanceStore.cs` | Implement **`ListForPlayer`** SQL |
|
||||||
|
| `server/NeonSprawl.Server/Program.cs` | Register **`MapContractIssueApi`** + **`MapContractListApi`** |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Contracts/InMemoryContractInstanceStoreTests.cs` | Unit tests for **`ListForPlayer`** ordering + cap |
|
||||||
|
| `server/README.md` | Document contract issue/list HTTP routes + samples |
|
||||||
|
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-151 HTTP anchor |
|
||||||
|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M4 alignment note (post-implementation) |
|
||||||
|
| `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru` | Cross-link contracts bru folder |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| File | What it covers |
|
||||||
|
|------|----------------|
|
||||||
|
| `InMemoryContractInstanceStoreTests` | **`ListForPlayer`**: empty, active-only, completed cap, ordering |
|
||||||
|
| `ContractApiTests` | POST issue happy/deny paths; GET empty/active/completed + **`completionRewardSummary`**; 404/400 gates |
|
||||||
|
| `ContractGeneratorOperationsIntegrationTests` (existing) | Unchanged — orchestrator DI baseline |
|
||||||
|
| CI | `dotnet test` green; Bruno manual against dev server |
|
||||||
|
|
||||||
|
Manual Godot QA: **none** (server HTTP; capstone **NEO-154**). Bruno: **`contracts/`** folder per plan.
|
||||||
|
|
||||||
|
## Open questions / risks
|
||||||
|
|
||||||
|
| Question / risk | Agent recommendation | Status |
|
||||||
|
|-----------------|----------------------|--------|
|
||||||
|
| **`TryIssue` registry wiring in HTTP handler** | Pass same four registries as **`ContractGeneratorOperationsIntegrationTests`** — verified at kickoff (NEO-150 handoff) | `adopted` |
|
||||||
|
| Duplicate grant-summary mapper vs quest API | **`QuestProgressApi.MapCompletionRewardSummary`** extended with `sourceKind` + `sourceId`; contract API reuses it | `adopted` |
|
||||||
|
| GET payload size with 10 completed rows | Accept for prototype; client (**NEO-153**) reads active row first | `adopted` |
|
||||||
|
| **`contract-seed.schema.json` lacks `schemaVersion`** | HTTP DTO adds **`schemaVersion`** alongside seed fields (quest/inventory precedent); schema file unchanged unless CI needs `$ref` later | `adopted` |
|
||||||
|
| Postgres **`ListForPlayer`** index | Existing **`player_id`** + status filter sufficient for prototype volume; no new migration | `adopted` |
|
||||||
|
|
||||||
|
## Client counterpart
|
||||||
|
|
||||||
|
[NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — **`contract_client.gd`**, issue binding, HUD labels; blocked until this story merges. Surfaces deny codes including **`economy_cap_exceeded`** and **`invalid_reward_bundle`** from NEO-150.
|
||||||
|
|
@ -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`**.
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||||
|
|
||||||
|
public sealed class ContractApiTests
|
||||||
|
{
|
||||||
|
private const string PlayerId = "dev-local-1";
|
||||||
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
||||||
|
private const string EncounterId = "prototype_combat_pocket";
|
||||||
|
private const string SeedBucket = "2026-06-28-neo151";
|
||||||
|
private const string DisplayName = "Clear Combat Pocket (Repeat)";
|
||||||
|
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 28, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
"/game/players/missing-player/contracts/issue",
|
||||||
|
JsonContent.Create(CreateIssueRequest("missing-player")));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostContractIssue_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
"/game/players/%20/contracts/issue",
|
||||||
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", SeedBucket)]
|
||||||
|
[InlineData(" ", SeedBucket)]
|
||||||
|
[InlineData(TemplateId, "")]
|
||||||
|
[InlineData(TemplateId, " ")]
|
||||||
|
public async Task PostContractIssue_ShouldReturnBadRequest_WhenRequiredSeedFieldsEmpty(
|
||||||
|
string templateId,
|
||||||
|
string seedBucket)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
$"/game/players/{PlayerId}/contracts/issue",
|
||||||
|
JsonContent.Create(
|
||||||
|
new ContractIssueRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = ContractIssueRequest.CurrentSchemaVersion,
|
||||||
|
PlayerId = PlayerId,
|
||||||
|
TemplateId = templateId,
|
||||||
|
SeedBucket = seedBucket,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostContractIssue_ShouldReturnBadRequest_WhenPlayerIdMismatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
$"/game/players/{PlayerId}/contracts/issue",
|
||||||
|
JsonContent.Create(CreateIssueRequest("other-player")));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostContractIssue_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
$"/game/players/{PlayerId}/contracts/issue",
|
||||||
|
JsonContent.Create(
|
||||||
|
new ContractIssueRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = 99,
|
||||||
|
PlayerId = PlayerId,
|
||||||
|
TemplateId = TemplateId,
|
||||||
|
SeedBucket = SeedBucket,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostContractIssue_ShouldReturnIssuedTrue_WhenPrototypeTemplateEligible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
|
||||||
|
PlayerId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
$"/game/players/{PlayerId}/contracts/issue",
|
||||||
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ContractIssueResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.Equal(ContractIssueResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||||
|
Assert.True(body.Issued);
|
||||||
|
Assert.Null(body.ReasonCode);
|
||||||
|
Assert.NotNull(body.Contract);
|
||||||
|
Assert.Equal(expectedInstanceId, body.Contract!.ContractInstanceId);
|
||||||
|
Assert.Equal(TemplateId, body.Contract.TemplateId);
|
||||||
|
Assert.Equal(DisplayName, body.Contract.TemplateDisplayName);
|
||||||
|
Assert.Equal(ContractListApi.StatusActive, body.Contract.Status);
|
||||||
|
Assert.Equal(EncounterId, body.Contract.EncounterTemplateId);
|
||||||
|
Assert.Equal(SeedBucket, body.Contract.SeedBucket);
|
||||||
|
Assert.Null(body.Contract.CompletedAt);
|
||||||
|
Assert.Null(body.Contract.CompletionRewardSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostContractIssue_ShouldReturnIssuedFalse_WhenActiveContractExists()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var first = await client.PostAsync(
|
||||||
|
$"/game/players/{PlayerId}/contracts/issue",
|
||||||
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
$"/game/players/{PlayerId}/contracts/issue",
|
||||||
|
JsonContent.Create(
|
||||||
|
new ContractIssueRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = ContractIssueRequest.CurrentSchemaVersion,
|
||||||
|
PlayerId = PlayerId,
|
||||||
|
TemplateId = TemplateId,
|
||||||
|
SeedBucket = "2026-06-28-neo151-second",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ContractIssueResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.False(body!.Issued);
|
||||||
|
Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, body.ReasonCode);
|
||||||
|
Assert.NotNull(body.Contract);
|
||||||
|
Assert.Equal(ContractListApi.StatusActive, body.Contract!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetContracts_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync("/game/players/missing-player/contracts");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetContracts_ShouldReturnEmptyList_WhenNoInstances()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ContractListResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.Equal(ContractListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||||
|
Assert.Equal(PlayerId, body.PlayerId);
|
||||||
|
Assert.Empty(body.Contracts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetContracts_ShouldReturnActiveRowAfterIssue_WithEncounterObjectiveId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var issue = await client.PostAsync(
|
||||||
|
$"/game/players/{PlayerId}/contracts/issue",
|
||||||
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, issue.StatusCode);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ContractListResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
var row = Assert.Single(body!.Contracts);
|
||||||
|
Assert.Equal(ContractListApi.StatusActive, row.Status);
|
||||||
|
Assert.Equal(EncounterId, row.EncounterTemplateId);
|
||||||
|
Assert.Null(row.CompletionRewardSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetContracts_ShouldReturnCompletionRewardSummary_WhenContractCompletedAfterEncounterClear()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
var issue = await client.PostAsync(
|
||||||
|
$"/game/players/{PlayerId}/contracts/issue",
|
||||||
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, issue.StatusCode);
|
||||||
|
var issueBody = await issue.Content.ReadFromJsonAsync<ContractIssueResponse>();
|
||||||
|
Assert.NotNull(issueBody?.Contract);
|
||||||
|
var instanceId = issueBody!.Contract!.ContractInstanceId;
|
||||||
|
var completed = ContractCompletionOperations.TryCompleteOnEncounterClear(
|
||||||
|
PlayerId,
|
||||||
|
EncounterId,
|
||||||
|
deps.InstanceStore,
|
||||||
|
deps.OutcomeStore,
|
||||||
|
deps.TemplateRegistry,
|
||||||
|
deps.ItemRegistry,
|
||||||
|
deps.InventoryStore,
|
||||||
|
deps.SkillRegistry,
|
||||||
|
deps.SkillProgressionStore,
|
||||||
|
deps.LevelCurve,
|
||||||
|
deps.PerkUnlockEngine,
|
||||||
|
deps.PerkStore,
|
||||||
|
deps.DeliveryStore,
|
||||||
|
deps.StandingStore,
|
||||||
|
deps.AuditStore,
|
||||||
|
deps.TimeProvider);
|
||||||
|
Assert.True(completed.Success);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ContractListResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
var row = Assert.Single(body!.Contracts);
|
||||||
|
Assert.Equal(instanceId, row.ContractInstanceId);
|
||||||
|
Assert.Equal(ContractListApi.StatusCompleted, row.Status);
|
||||||
|
Assert.NotNull(row.CompletedAt);
|
||||||
|
AssertPrototypeCompletionRewardSummary(row.CompletionRewardSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetContracts_ShouldReturnSameCompletionRewardSummary_WhenReplayed()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var deps = ResolveDependencies(factory);
|
||||||
|
_ = await client.PostAsync(
|
||||||
|
$"/game/players/{PlayerId}/contracts/issue",
|
||||||
|
JsonContent.Create(CreateIssueRequest(PlayerId)));
|
||||||
|
_ = ContractCompletionOperations.TryCompleteOnEncounterClear(
|
||||||
|
PlayerId,
|
||||||
|
EncounterId,
|
||||||
|
deps.InstanceStore,
|
||||||
|
deps.OutcomeStore,
|
||||||
|
deps.TemplateRegistry,
|
||||||
|
deps.ItemRegistry,
|
||||||
|
deps.InventoryStore,
|
||||||
|
deps.SkillRegistry,
|
||||||
|
deps.SkillProgressionStore,
|
||||||
|
deps.LevelCurve,
|
||||||
|
deps.PerkUnlockEngine,
|
||||||
|
deps.PerkStore,
|
||||||
|
deps.DeliveryStore,
|
||||||
|
deps.StandingStore,
|
||||||
|
deps.AuditStore,
|
||||||
|
deps.TimeProvider);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var first = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
||||||
|
var second = await client.GetAsync($"/game/players/{PlayerId}/contracts");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var firstBody = await first.Content.ReadFromJsonAsync<ContractListResponse>();
|
||||||
|
var secondBody = await second.Content.ReadFromJsonAsync<ContractListResponse>();
|
||||||
|
Assert.NotNull(firstBody);
|
||||||
|
Assert.NotNull(secondBody);
|
||||||
|
AssertCompletionRewardSummariesEqual(
|
||||||
|
Assert.Single(firstBody!.Contracts).CompletionRewardSummary,
|
||||||
|
Assert.Single(secondBody!.Contracts).CompletionRewardSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContractIssueRequest CreateIssueRequest(string playerId) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
SchemaVersion = ContractIssueRequest.CurrentSchemaVersion,
|
||||||
|
PlayerId = playerId,
|
||||||
|
TemplateId = TemplateId,
|
||||||
|
SeedBucket = SeedBucket,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void AssertPrototypeCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary)
|
||||||
|
{
|
||||||
|
Assert.NotNull(summary);
|
||||||
|
var item = Assert.Single(summary!.ItemGrants);
|
||||||
|
Assert.Equal("scrap_metal_bulk", item.ItemId);
|
||||||
|
Assert.Equal(5, item.Quantity);
|
||||||
|
var skill = Assert.Single(summary.SkillXpGrants);
|
||||||
|
Assert.Equal("salvage", skill.SkillId);
|
||||||
|
Assert.Equal(15, skill.Amount);
|
||||||
|
Assert.Empty(summary.ReputationGrants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertCompletionRewardSummariesEqual(
|
||||||
|
QuestCompletionRewardSummaryJson? first,
|
||||||
|
QuestCompletionRewardSummaryJson? second)
|
||||||
|
{
|
||||||
|
Assert.NotNull(first);
|
||||||
|
Assert.NotNull(second);
|
||||||
|
AssertPrototypeCompletionRewardSummary(first);
|
||||||
|
AssertPrototypeCompletionRewardSummary(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var services = scope.ServiceProvider;
|
||||||
|
return new ApiTestDependencies(
|
||||||
|
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||||
|
services.GetRequiredService<IContractInstanceStore>(),
|
||||||
|
services.GetRequiredService<IContractOutcomeStore>(),
|
||||||
|
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||||
|
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||||
|
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||||
|
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||||
|
services.GetRequiredService<ISkillLevelCurve>(),
|
||||||
|
services.GetRequiredService<PerkUnlockEngine>(),
|
||||||
|
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||||
|
services.GetRequiredService<IRewardDeliveryStore>(),
|
||||||
|
services.GetRequiredService<IFactionStandingStore>(),
|
||||||
|
services.GetRequiredService<IReputationDeltaStore>(),
|
||||||
|
new FakeTimeProvider(CompletedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
|
||||||
|
{
|
||||||
|
public override DateTimeOffset GetUtcNow() => utcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ApiTestDependencies(
|
||||||
|
IContractTemplateRegistry TemplateRegistry,
|
||||||
|
IContractInstanceStore InstanceStore,
|
||||||
|
IContractOutcomeStore OutcomeStore,
|
||||||
|
IItemDefinitionRegistry ItemRegistry,
|
||||||
|
IPlayerInventoryStore InventoryStore,
|
||||||
|
ISkillDefinitionRegistry SkillRegistry,
|
||||||
|
IPlayerSkillProgressionStore SkillProgressionStore,
|
||||||
|
ISkillLevelCurve LevelCurve,
|
||||||
|
PerkUnlockEngine PerkUnlockEngine,
|
||||||
|
IPlayerPerkStateStore PerkStore,
|
||||||
|
IRewardDeliveryStore DeliveryStore,
|
||||||
|
IFactionStandingStore StandingStore,
|
||||||
|
IReputationDeltaStore AuditStore,
|
||||||
|
TimeProvider TimeProvider);
|
||||||
|
}
|
||||||
|
|
@ -407,5 +407,8 @@ public sealed class ContractGeneratorOperationsTests
|
||||||
|
|
||||||
public bool TryClearInstance(string playerId, string contractInstanceId) =>
|
public bool TryClearInstance(string playerId, string contractInstanceId) =>
|
||||||
inner.TryClearInstance(playerId, contractInstanceId);
|
inner.TryClearInstance(playerId, contractInstanceId);
|
||||||
|
|
||||||
|
public IReadOnlyList<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows) =>
|
||||||
|
inner.ListForPlayer(playerId, maxCompletedRows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,55 @@ public sealed class ContractInstancePersistenceIntegrationTests(PostgresIntegrat
|
||||||
Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId);
|
Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RequirePostgresFact]
|
||||||
|
public async Task ListForPlayer_ShouldPrependActiveAndCapCompletedRows_OnPostgres()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await ResetContractInstanceTableAsync();
|
||||||
|
using var scope = Factory.Services.CreateScope();
|
||||||
|
var store = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
||||||
|
for (var i = 0; i < 12; i++)
|
||||||
|
{
|
||||||
|
var instanceId = $"prototype_contract_instance_pg_done_{i:D2}";
|
||||||
|
Assert.True(store.TryCreateActive(
|
||||||
|
PlayerId,
|
||||||
|
instanceId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket,
|
||||||
|
IssuedAt.AddHours(i),
|
||||||
|
out _));
|
||||||
|
Assert.True(store.TryMarkComplete(
|
||||||
|
PlayerId,
|
||||||
|
instanceId,
|
||||||
|
CompletedAt.AddHours(i),
|
||||||
|
out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
const string activeId = "prototype_contract_instance_pg_active";
|
||||||
|
Assert.True(store.TryCreateActive(
|
||||||
|
PlayerId,
|
||||||
|
activeId,
|
||||||
|
TemplateId,
|
||||||
|
SeedBucket,
|
||||||
|
IssuedAt.AddDays(3),
|
||||||
|
out _));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(11, rows.Count);
|
||||||
|
Assert.Equal(activeId, rows[0].ContractInstanceId);
|
||||||
|
Assert.Equal(ContractInstanceStatus.Active, rows[0].Status);
|
||||||
|
Assert.All(rows.Skip(1), row => Assert.Equal(ContractInstanceStatus.Completed, row.Status));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
rows.Skip(1),
|
||||||
|
row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_pg_done_00", StringComparison.Ordinal));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
rows.Skip(1),
|
||||||
|
row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_pg_done_01", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
[RequirePostgresFact]
|
[RequirePostgresFact]
|
||||||
public async Task CreateComplete_ShouldPersistAcrossNewFactory()
|
public async Task CreateComplete_ShouldPersistAcrossNewFactory()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,127 @@ public sealed class InMemoryContractInstanceStoreTests
|
||||||
second.Status == ContractInstanceStatus.Active);
|
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]
|
[Fact]
|
||||||
public void TryClearInstance_ShouldReturnTrue_WhenRowMissing()
|
public void TryClearInstance_ShouldReturnTrue_WhenRowMissing()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>POST body for <c>POST /game/players/{{id}}/contracts/issue</c> (NEO-151).</summary>
|
||||||
|
public sealed class ContractIssueRequest
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("playerId")]
|
||||||
|
public string? PlayerId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("templateId")]
|
||||||
|
public string? TemplateId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("seedBucket")]
|
||||||
|
public string? SeedBucket { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("zoneDifficultyBand")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public int? ZoneDifficultyBand { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST response for contract issue (NEO-151).</summary>
|
||||||
|
public sealed class ContractIssueResponse
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
|
||||||
|
[JsonPropertyName("issued")]
|
||||||
|
public bool Issued { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("reasonCode")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public string? ReasonCode { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("contract")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public ContractInstanceRowJson? Contract { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>JSON body for <c>GET /game/players/{{id}}/contracts</c> (NEO-151).</summary>
|
||||||
|
public sealed class ContractListResponse
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
|
||||||
|
[JsonPropertyName("playerId")]
|
||||||
|
public required string PlayerId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("contracts")]
|
||||||
|
public required IReadOnlyList<ContractInstanceRowJson> Contracts { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Authoritative contract instance projection for HTTP clients (NEO-151).</summary>
|
||||||
|
public sealed class ContractInstanceRowJson
|
||||||
|
{
|
||||||
|
[JsonPropertyName("contractInstanceId")]
|
||||||
|
public required string ContractInstanceId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("templateId")]
|
||||||
|
public required string TemplateId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("templateDisplayName")]
|
||||||
|
public required string TemplateDisplayName { get; init; }
|
||||||
|
|
||||||
|
/// <summary><c>active</c> or <c>completed</c>.</summary>
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public required string Status { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("encounterTemplateId")]
|
||||||
|
public required string EncounterTemplateId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("seedBucket")]
|
||||||
|
public required string SeedBucket { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("issuedAt")]
|
||||||
|
public DateTimeOffset IssuedAt { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("completedAt")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public DateTimeOffset? CompletedAt { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("completionRewardSummary")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Maps <c>POST /game/players/{{id}}/contracts/issue</c> (NEO-151).</summary>
|
||||||
|
public static class ContractIssueApi
|
||||||
|
{
|
||||||
|
public static WebApplication MapContractIssueApi(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost(
|
||||||
|
"/game/players/{id}/contracts/issue",
|
||||||
|
(string id, ContractIssueRequest? body, IPositionStateStore positions,
|
||||||
|
IContractTemplateRegistry templateRegistry, IEncounterDefinitionRegistry encounterRegistry,
|
||||||
|
IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry,
|
||||||
|
IFactionDefinitionRegistry factionRegistry, IContractInstanceStore instanceStore,
|
||||||
|
IFactionStandingStore standingStore, IRewardDeliveryStore deliveryStore,
|
||||||
|
TimeProvider timeProvider) =>
|
||||||
|
{
|
||||||
|
var trimmedId = id.Trim();
|
||||||
|
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body is null || body.SchemaVersion != ContractIssueRequest.CurrentSchemaVersion)
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(body.TemplateId);
|
||||||
|
var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(body.SeedBucket);
|
||||||
|
var normalizedBodyPlayerId = ContractInstanceIds.NormalizePlayerId(body.PlayerId);
|
||||||
|
if (normalizedTemplateId.Length == 0 ||
|
||||||
|
normalizedSeedBucket.Length == 0 ||
|
||||||
|
normalizedBodyPlayerId.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedPathPlayerId = ContractInstanceIds.NormalizePlayerId(trimmedId);
|
||||||
|
if (!string.Equals(normalizedBodyPlayerId, normalizedPathPlayerId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = ContractGeneratorOperations.TryIssue(
|
||||||
|
trimmedId,
|
||||||
|
normalizedTemplateId,
|
||||||
|
normalizedSeedBucket,
|
||||||
|
body.ZoneDifficultyBand,
|
||||||
|
templateRegistry,
|
||||||
|
encounterRegistry,
|
||||||
|
itemRegistry,
|
||||||
|
skillRegistry,
|
||||||
|
factionRegistry,
|
||||||
|
instanceStore,
|
||||||
|
standingStore,
|
||||||
|
timeProvider);
|
||||||
|
|
||||||
|
return Results.Json(
|
||||||
|
MapIssueResponse(trimmedId, result, templateRegistry, deliveryStore));
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ContractIssueResponse MapIssueResponse(
|
||||||
|
string playerId,
|
||||||
|
ContractIssueOperationResult result,
|
||||||
|
IContractTemplateRegistry templateRegistry,
|
||||||
|
IRewardDeliveryStore deliveryStore)
|
||||||
|
{
|
||||||
|
ContractInstanceRowJson? contract = null;
|
||||||
|
if (result.Snapshot is { } snapshot)
|
||||||
|
{
|
||||||
|
contract = ContractListApi.MapInstanceRow(
|
||||||
|
playerId,
|
||||||
|
snapshot,
|
||||||
|
templateRegistry,
|
||||||
|
deliveryStore,
|
||||||
|
result.EncounterTemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ContractIssueResponse
|
||||||
|
{
|
||||||
|
Issued = result.Success,
|
||||||
|
ReasonCode = result.ReasonCode,
|
||||||
|
Contract = contract,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Maps <c>GET /game/players/{{id}}/contracts</c> (NEO-151).</summary>
|
||||||
|
public static class ContractListApi
|
||||||
|
{
|
||||||
|
public const int MaxCompletedRows = 10;
|
||||||
|
|
||||||
|
public const string StatusActive = "active";
|
||||||
|
public const string StatusCompleted = "completed";
|
||||||
|
|
||||||
|
public static WebApplication MapContractListApi(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapGet(
|
||||||
|
"/game/players/{id}/contracts",
|
||||||
|
(string id, IPositionStateStore positions, IContractInstanceStore instanceStore,
|
||||||
|
IContractTemplateRegistry templateRegistry, IRewardDeliveryStore deliveryStore) =>
|
||||||
|
{
|
||||||
|
var trimmedId = id.Trim();
|
||||||
|
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshots = instanceStore.ListForPlayer(trimmedId, MaxCompletedRows);
|
||||||
|
var rows = new List<ContractInstanceRowJson>(snapshots.Count);
|
||||||
|
foreach (var snapshot in snapshots)
|
||||||
|
{
|
||||||
|
rows.Add(MapInstanceRow(trimmedId, snapshot, templateRegistry, deliveryStore));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(
|
||||||
|
new ContractListResponse
|
||||||
|
{
|
||||||
|
PlayerId = trimmedId,
|
||||||
|
Contracts = rows,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ContractInstanceRowJson MapInstanceRow(
|
||||||
|
string playerId,
|
||||||
|
ContractInstanceState snapshot,
|
||||||
|
IContractTemplateRegistry templateRegistry,
|
||||||
|
IRewardDeliveryStore deliveryStore,
|
||||||
|
string? encounterTemplateIdOverride = null)
|
||||||
|
{
|
||||||
|
var templateDisplayName = snapshot.TemplateId;
|
||||||
|
var encounterTemplateId = encounterTemplateIdOverride ?? string.Empty;
|
||||||
|
if (templateRegistry.TryGetDefinition(snapshot.TemplateId, out var template))
|
||||||
|
{
|
||||||
|
templateDisplayName = template.DisplayName;
|
||||||
|
if (encounterTemplateId.Length == 0)
|
||||||
|
{
|
||||||
|
encounterTemplateId = template.EncounterTemplateId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QuestCompletionRewardSummaryJson? completionRewardSummary = null;
|
||||||
|
if (snapshot.Status == ContractInstanceStatus.Completed)
|
||||||
|
{
|
||||||
|
completionRewardSummary = QuestProgressApi.MapCompletionRewardSummary(
|
||||||
|
playerId,
|
||||||
|
RewardDeliverySourceKinds.ContractCompletion,
|
||||||
|
snapshot.ContractInstanceId,
|
||||||
|
deliveryStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ContractInstanceRowJson
|
||||||
|
{
|
||||||
|
ContractInstanceId = snapshot.ContractInstanceId,
|
||||||
|
TemplateId = snapshot.TemplateId,
|
||||||
|
TemplateDisplayName = templateDisplayName,
|
||||||
|
Status = MapStatus(snapshot.Status),
|
||||||
|
EncounterTemplateId = encounterTemplateId,
|
||||||
|
SeedBucket = snapshot.SeedBucket,
|
||||||
|
IssuedAt = snapshot.IssuedAt,
|
||||||
|
CompletedAt = snapshot.CompletedAt,
|
||||||
|
CompletionRewardSummary = completionRewardSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapStatus(ContractInstanceStatus status) =>
|
||||||
|
status switch
|
||||||
|
{
|
||||||
|
ContractInstanceStatus.Active => StatusActive,
|
||||||
|
ContractInstanceStatus.Completed => StatusCompleted,
|
||||||
|
_ => throw new InvalidOperationException($"Unknown contract instance status '{status}'."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -36,4 +36,10 @@ public interface IContractInstanceStore
|
||||||
|
|
||||||
/// <summary>Dev fixture only — removes one instance row; idempotent when absent.</summary>
|
/// <summary>Dev fixture only — removes one instance row; idempotent when absent.</summary>
|
||||||
bool TryClearInstance(string playerId, string contractInstanceId);
|
bool TryClearInstance(string playerId, string contractInstanceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Active row first (when present), then up to <paramref name="maxCompletedRows"/> completed rows
|
||||||
|
/// for the player ordered by <see cref="ContractInstanceState.IssuedAt"/> descending.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,62 @@ public sealed class InMemoryContractInstanceStore(IOptions<GamePositionOptions>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows)
|
||||||
|
{
|
||||||
|
var player = ContractInstanceIds.NormalizePlayerId(playerId);
|
||||||
|
if (player.Length == 0 || !knownPlayers.Contains(player) || maxCompletedRows < 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
||||||
|
{
|
||||||
|
ContractInstanceState? active = null;
|
||||||
|
if (activeInstanceByPlayer.TryGetValue(player, out var activeId) &&
|
||||||
|
byInstanceId.TryGetValue(activeId, out var activeRow) &&
|
||||||
|
activeRow.Status == ContractInstanceStatus.Active &&
|
||||||
|
string.Equals(activeRow.PlayerId, player, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
active = activeRow.ToSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
var completed = new List<ContractInstanceState>();
|
||||||
|
foreach (var row in byInstanceId.Values)
|
||||||
|
{
|
||||||
|
if (!string.Equals(row.PlayerId, player, StringComparison.Ordinal) ||
|
||||||
|
row.Status != ContractInstanceStatus.Completed)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
completed.Add(row.ToSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
completed.Sort(static (a, b) =>
|
||||||
|
{
|
||||||
|
var cmp = b.IssuedAt.CompareTo(a.IssuedAt);
|
||||||
|
return cmp != 0
|
||||||
|
? cmp
|
||||||
|
: string.Compare(a.ContractInstanceId, b.ContractInstanceId, StringComparison.Ordinal);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (completed.Count > maxCompletedRows)
|
||||||
|
{
|
||||||
|
completed.RemoveRange(maxCompletedRows, completed.Count - maxCompletedRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active is null)
|
||||||
|
{
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<ContractInstanceState>(1 + completed.Count) { active };
|
||||||
|
result.AddRange(completed);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool TryClearInstance(string playerId, string contractInstanceId)
|
public bool TryClearInstance(string playerId, string contractInstanceId)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,60 @@ public sealed class PostgresContractInstanceStore(Npgsql.NpgsqlDataSource dataSo
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows)
|
||||||
|
{
|
||||||
|
var player = ContractInstanceIds.NormalizePlayerId(playerId);
|
||||||
|
if (player.Length == 0 || maxCompletedRows < 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresContractInstanceBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
if (!PlayerExists(conn, player))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single statement snapshot — avoids active+completed duplicate rows if status flips mid-read.
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
WITH active_row AS (
|
||||||
|
SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 0 AS section
|
||||||
|
FROM contract_instance
|
||||||
|
WHERE player_id = @pid AND status = 'active'
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
completed_rows AS (
|
||||||
|
SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at, 1 AS section
|
||||||
|
FROM contract_instance
|
||||||
|
WHERE player_id = @pid AND status = 'completed'
|
||||||
|
ORDER BY issued_at DESC, contract_instance_id ASC
|
||||||
|
LIMIT @limit
|
||||||
|
)
|
||||||
|
SELECT contract_instance_id, template_id, player_id, status, seed_bucket, issued_at, completed_at
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM active_row
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM completed_rows
|
||||||
|
) AS listed
|
||||||
|
ORDER BY section, issued_at DESC, contract_instance_id ASC;
|
||||||
|
""",
|
||||||
|
conn);
|
||||||
|
cmd.Parameters.AddWithValue("pid", player);
|
||||||
|
cmd.Parameters.AddWithValue("limit", maxCompletedRows);
|
||||||
|
|
||||||
|
var result = new List<ContractInstanceState>();
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
result.Add(ReadSnapshot(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool TryClearInstance(string playerId, string contractInstanceId)
|
public bool TryClearInstance(string playerId, string contractInstanceId)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -121,20 +121,34 @@ public static class QuestProgressApi
|
||||||
private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary(
|
private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary(
|
||||||
string playerId,
|
string playerId,
|
||||||
string questId,
|
string questId,
|
||||||
|
IRewardDeliveryStore deliveryStore) =>
|
||||||
|
MapCompletionRewardSummary(
|
||||||
|
playerId,
|
||||||
|
RewardDeliverySourceKinds.QuestCompletion,
|
||||||
|
questId,
|
||||||
|
deliveryStore);
|
||||||
|
|
||||||
|
internal static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary(
|
||||||
|
string playerId,
|
||||||
|
string sourceKind,
|
||||||
|
string sourceId,
|
||||||
IRewardDeliveryStore deliveryStore)
|
IRewardDeliveryStore deliveryStore)
|
||||||
{
|
{
|
||||||
if (!deliveryStore.TryGet(playerId, questId, out var deliveryEvent))
|
if (!deliveryStore.TryGet(playerId, sourceKind, sourceId, out var deliveryEvent))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new QuestCompletionRewardSummaryJson
|
return MapCompletionRewardSummary(deliveryEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static QuestCompletionRewardSummaryJson MapCompletionRewardSummary(RewardDeliveryEvent deliveryEvent) =>
|
||||||
|
new()
|
||||||
{
|
{
|
||||||
ItemGrants = MapItemGrants(deliveryEvent.GrantedItems),
|
ItemGrants = MapItemGrants(deliveryEvent.GrantedItems),
|
||||||
SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp),
|
SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp),
|
||||||
ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation),
|
ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation),
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedItems"/>.</summary>
|
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedItems"/>.</summary>
|
||||||
private static List<QuestItemGrantJson> MapItemGrants(IReadOnlyList<RewardItemGrantApplied> grants)
|
private static List<QuestItemGrantJson> MapItemGrants(IReadOnlyList<RewardItemGrantApplied> grants)
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ app.MapFactionStandingApi();
|
||||||
app.MapEncounterProgressApi();
|
app.MapEncounterProgressApi();
|
||||||
app.MapQuestProgressApi();
|
app.MapQuestProgressApi();
|
||||||
app.MapQuestAcceptApi();
|
app.MapQuestAcceptApi();
|
||||||
|
app.MapContractIssueApi();
|
||||||
|
app.MapContractListApi();
|
||||||
app.MapPerkStateApi();
|
app.MapPerkStateApi();
|
||||||
if (app.Environment.IsDevelopment() ||
|
if (app.Environment.IsDevelopment() ||
|
||||||
app.Environment.IsEnvironment("Testing") ||
|
app.Environment.IsEnvironment("Testing") ||
|
||||||
|
|
|
||||||
|
|
@ -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`**.
|
**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)
|
## 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.
|
- **`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`**.
|
- **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**.
|
||||||
- **`TryClearInstance`** — dev fixture only; removes one instance row.
|
- **`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).
|
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.
|
**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)
|
## 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.
|
**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)
|
## Contract outcome store (NEO-146)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue