Merge pull request #180 from ViPro-Technologies/NEO-140-e7m3-quest-http-rep-gate-projections

NEO-140: Extend quest HTTP projections for rep + gates (E7M3-08)
pull/183/head
VinPropane 2026-06-17 19:19:15 -04:00 committed by GitHub
commit 960b4900d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 522 additions and 6 deletions

View File

@ -68,5 +68,16 @@ tests {
itemId: "survey_drone_kit", itemId: "survey_drone_kit",
quantity: 1, quantity: 1,
}); });
expect(row.factionGateRules).to.eql([
{ factionId: "prototype_faction_grid_operators", minStanding: 15 },
]);
});
test("non-gated quests omit factionGateRules", function () {
const body = res.getBody();
const gatherIntro = body.quests.find((x) => x.id === "prototype_quest_gather_intro");
expect(gatherIntro.factionGateRules).to.equal(undefined);
const operatorChain = body.quests.find((x) => x.id === "prototype_quest_operator_chain");
expect(operatorChain.factionGateRules).to.equal(undefined);
}); });
} }

View File

@ -38,5 +38,11 @@ tests {
expect(body.quest).to.be.an("object"); expect(body.quest).to.be.an("object");
expect(body.quest.questId).to.equal("prototype_quest_grid_contract"); expect(body.quest.questId).to.equal("prototype_quest_grid_contract");
expect(body.quest.status).to.equal("completed"); expect(body.quest.status).to.equal("completed");
expect(body.quest.completionRewardSummary).to.be.an("object");
expect(body.quest.completionRewardSummary.itemGrants).to.eql([]);
expect(body.quest.completionRewardSummary.skillXpGrants).to.eql([]);
expect(body.quest.completionRewardSummary.reputationGrants).to.eql([
{ factionId: "prototype_faction_rust_collective", amount: 10 },
]);
}); });
} }

View File

@ -139,6 +139,7 @@ tests {
expect(row.completionRewardSummary.skillXpGrants).to.eql([ expect(row.completionRewardSummary.skillXpGrants).to.eql([
{ skillId: "salvage", amount: 25 }, { skillId: "salvage", amount: 25 },
]); ]);
expect(row.completionRewardSummary.reputationGrants).to.eql([]);
}); });
test("second GET completionRewardSummary matches first GET", function () { test("second GET completionRewardSummary matches first GET", function () {

View File

@ -0,0 +1,67 @@
meta {
name: GET quest progress after operator chain complete
type: http
seq: 12
}
docs {
NEO-140: complete operator chain via HTTP quest flow, GET quest-progress asserts completionRewardSummary.reputationGrants (+15 Grid Operators).
Pre-request runs operator-chain-quest-flow-helper (gather/combat/refine intros + chain steps).
}
script:pre-request {
const { completeOperatorChainQuestFlow, CHAIN_QUEST_ID } = require("./scripts/operator-chain-quest-flow-helper");
await completeOperatorChainQuestFlow(bru);
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const response = await axios.get(
`${baseUrl}/game/players/${playerId}/quest-progress`,
{ validateStatus: () => true },
);
if (response.status !== 200) {
throw new Error(`quest-progress GET failed: ${response.status}`);
}
const row = response.data.quests.find((x) => x.questId === CHAIN_QUEST_ID);
if (!row || row.status !== "completed" || !row.completionRewardSummary) {
throw new Error(`expected operator chain completed with summary, got ${JSON.stringify(row)}`);
}
bru.setVar(
"firstOperatorChainCompletionRewardSummary",
JSON.stringify(row.completionRewardSummary),
);
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/quest-progress
body: none
auth: none
}
tests {
test("operator chain row is completed with reputationGrants in completionRewardSummary", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
const row = body.quests.find((x) => x.questId === "prototype_quest_operator_chain");
expect(row).to.be.an("object");
expect(row.status).to.equal("completed");
expect(row.completionRewardSummary).to.be.an("object");
expect(row.completionRewardSummary.itemGrants).to.eql([
{ itemId: "survey_drone_kit", quantity: 1 },
]);
expect(row.completionRewardSummary.skillXpGrants).to.eql([
{ skillId: "salvage", amount: 50 },
]);
expect(row.completionRewardSummary.reputationGrants).to.eql([
{ factionId: "prototype_faction_grid_operators", amount: 15 },
]);
});
test("second GET completionRewardSummary matches first GET", function () {
const body = res.getBody();
const row = body.quests.find((x) => x.questId === "prototype_quest_operator_chain");
const firstSummary = JSON.parse(bru.getVar("firstOperatorChainCompletionRewardSummary"));
expect(row.completionRewardSummary).to.eql(firstSummary);
});
}

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M3 | | **Module ID** | E7.M3 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Pre-production | | **Stage target** | Pre-production |
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); **E7M3-07 faction standing HTTP read landed** ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)): `GET …/faction-standing` snapshot API + Bruno ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-08** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | | **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); **E7M3-07 faction standing HTTP read landed** ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)): `GET …/faction-standing` snapshot API + Bruno ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); **E7M3-08 quest HTTP projections landed** ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)): world GET `factionGateRules` + quest-progress `completionRewardSummary.reputationGrants` ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)). Slice 3 backlog **E7M3-09** [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
## Purpose ## Purpose

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,40 @@
# NEO-140 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-140 |
| Title | E7M3-08: Extend quest HTTP projections for rep + gates |
| Linear | https://linear.app/neon-sprawl/issue/NEO-140/e7m3-08-extend-quest-http-projections-for-rep-gates |
| Plan | `docs/plans/NEO-140-implementation-plan.md` |
| Branch | `NEO-140-e7m3-quest-http-rep-gate-projections` |
## Preconditions
- Server running: `cd server/NeonSprawl.Server && dotnet run` (in-memory dev player `dev-local-1`).
- Bruno collection `bruno/neon-sprawl-server` loaded with `baseUrl` = `http://localhost:5253`, `playerId` = `dev-local-1`.
- NEO-138 reward router rep grants and NEO-139 faction-standing GET landed on `main`.
## Setup sanity
- [ ] `curl -sS http://localhost:5253/health` returns `"status":"ok"`.
- [ ] `dotnet test NeonSprawl.sln` passes (821+ tests).
## Quest definitions — factionGateRules
- [ ] Run Bruno **`quest-definitions/Get quest definitions`**. Response **200**; `prototype_quest_grid_contract.factionGateRules` = `[{ factionId: prototype_faction_grid_operators, minStanding: 15 }]`.
- [ ] Same response: `prototype_quest_gather_intro` and `prototype_quest_operator_chain` have **no** `factionGateRules` key.
- [ ] `curl -sS http://localhost:5253/game/world/quest-definitions | jq '.quests[] | select(.id=="prototype_quest_grid_contract") | .factionGateRules'` matches the Bruno assertion.
## Quest progress — completionRewardSummary.reputationGrants
- [ ] Run Bruno **`quest-progress/Get quest progress after gather intro complete`**. Gather row `completionRewardSummary.reputationGrants` = `[]`.
- [ ] Run Bruno **`quest-progress/Get quest progress after operator chain complete`**. Operator-chain row `completionRewardSummary.reputationGrants` = `[{ factionId: prototype_faction_grid_operators, amount: 15 }]`; item and skill XP lines unchanged from NEO-129 freeze.
- [ ] Second GET in the operator-chain bru matches the first (idempotent summary).
## No regressions
- [ ] Bruno **`faction-standing/Get faction standing after operator chain`** still shows Grid Operators standing **15** after operator-chain flow.
- [ ] Bruno **`quest-progress/Accept grid contract after operator chain`** accepts grid contract when rep gate met and embedded row **`completionRewardSummary.reputationGrants`** = `[{ factionId: prototype_faction_rust_collective, amount: 10 }]`.
- [ ] NEO-129 gather-intro **`completionRewardSummary`** shape unchanged except new empty **`reputationGrants`** array.
**Client HUD verification** deferred to [NEO-142 (Godot HUD)](https://linear.app/neon-sprawl/issue/NEO-142/e7m3-10-client-faction-standing-gate-feedback-hud-godot) — this story is server HTTP projection only.

View File

@ -0,0 +1,181 @@
# NEO-140 — E7M3-08: Extend quest HTTP projections for rep + gates
**Linear:** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)
**Branch:** `NEO-140-e7m3-quest-http-rep-gate-projections`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-08**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-129](NEO-129-implementation-plan.md) — `completionRewardSummary` nested grant lines from `RewardDeliveryEvent`; [NEO-115](NEO-115-implementation-plan.md) — world GET definition projection
**Precursors:** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) **`Done`** — `RewardDeliveryEvent.GrantedReputation` at commit time; [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) **`Done`** — standing GET for client cross-check
**Blocks:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) (Godot HUD gate + rep reward labels)
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — parses world **`factionGateRules`** and quest-progress **`completionRewardSummary.reputationGrants`**; blocked until this story lands
## Goal
Client can see gate requirements and completion rep summary without local math.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M3-08 backlog scope](E7M3-pre-production-backlog.md#e7m3-08--extend-quest-http-projections-for-rep-grants--gate-rules), and NEO-129/NEO-138 precedents settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| **`factionGateRules` JSON shape** | **`{ factionId, minStanding }`** per row — flat mirror of content schema | `content/schemas/faction-gate-rule.schema.json`; `FactionGateRuleRow` |
| **`factionGateRules` omission** | **Omit property** when quest has no rules (`JsonIgnore WhenWritingNull`) | Backlog “optional projection”; NEO-115 objective optional-field precedent |
| **`factionGateRules` order** | **Preserve catalog load order** from `QuestDefRow.FactionGateRules` | Same as prerequisite ids / step order in world GET |
| **Faction display names on gates** | **Not projected**`factionId` only | NEO-139 deferred `GET /game/world/faction-definitions`; client joins standing GET or future catalog |
| **`reputationGrants` in summary** | **Required array** on `completionRewardSummary` when summary is present; **empty `[]`** when delivery had no rep rows | NEO-129: `itemGrants` / `skillXpGrants` always present when summary exists |
| **Rep line field names** | **`factionId`**, **`amount`** — mirror content `reputation-grant-row.schema.json` and `RewardReputationGrantApplied` | NEO-138 store snapshot; content schema |
| **Rep line source** | **`RewardDeliveryEvent.GrantedReputation`** at commit time (not live standing recompute) | NEO-129 maps delivery event, not bundle definition |
| **Rep line order** | **Preserve commit-time grant order** from delivery event | `MapItemGrants` / `MapSkillXpGrants` XML doc precedent |
| **Schema version** | **Stay at 1** for both world quest-definitions and quest-progress | Additive optional fields; no breaking rename |
| **`QuestAcceptApi`** | **No separate accept-specific changes** — shared `QuestProgressApi.MapQuestProgressRow` / `MapCompletionRewardSummary` picks up rep lines automatically | Backlog “if applicable”; accept embeds progress row via existing mapper |
| **Bruno scope** | **Extend** `quest-definitions/Get quest definitions.bru` (grid-contract gate row) + **add** quest-progress GET bru asserting operator-chain **`reputationGrants`** (+15 Grid Operators) | Backlog “Bruno assertions on grid-contract quest row”; closes NEO-138 deferral on summary shape |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- Extend **`GET /game/world/quest-definitions`** with optional **`factionGateRules`** per quest.
- Extend **`GET …/quest-progress`** **`completionRewardSummary`** with **`reputationGrants`** lines (mirror items/skill XP).
- **`QuestAcceptApi`** embedded quest rows inherit updated mapper (no extra accept-only DTO work).
- Integration tests + Bruno assertions on **`prototype_quest_grid_contract`** gate row and operator-chain rep summary.
- `server/README.md` quest + faction sections.
**Out of scope:**
- Godot parse/HUD (NEO-142), telemetry ingest (NEO-141).
- **`GET /game/world/faction-definitions`** (no backlog item).
- Player-specific gate pass/fail on world GET (gates evaluated only on accept; standing via NEO-139 GET).
- Enriching accept deny responses with failing rule detail (NEO-137 adopted: `reasonCode` only).
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — standing label + gate feedback + reward label rep lines after NEO-139/NEO-140.
## Acceptance criteria checklist
- [x] Gated quest definition includes **`factionGateRules`** in world GET.
- [x] Completed quest rows include rep lines in **`completionRewardSummary`** when delivered.
- [x] `dotnet test` covers grid-contract gate projection and operator-chain rep summary.
- [x] Bruno asserts grid-contract **`factionGateRules`** and operator-chain **`reputationGrants`**.
## Implementation reconciliation (shipped)
- **World GET:** `QuestDefinitionsWorldApi` maps `QuestDefRow.FactionGateRules` → optional `factionGateRules` on `QuestDefinitionJson` (`QuestFactionGateRuleJson`: `factionId`, `minStanding`); omitted when empty.
- **Quest progress GET:** `QuestProgressApi.MapCompletionRewardSummary` maps `RewardDeliveryEvent.GrantedReputation``reputationGrants` (`QuestReputationGrantJson`: `factionId`, `amount`); required array when summary present.
- **Accept POST:** inherits updated mapper via `QuestProgressApi.MapQuestProgressRow` — no accept-specific changes.
- **Tests:** `QuestDefinitionsWorldApiTests` grid-contract gate row + wire-level `factionGateRules` omission; `QuestProgressApiTests` operator-chain +15 rep, grid-contract +10 rep, gather-intro empty rep; **823** tests green.
- **Bruno:** extended `Get quest definitions.bru`; new `Get quest progress after operator chain complete.bru`; gather-intro bru asserts empty `reputationGrants`.
- **Docs:** `server/README.md` quest-definitions + quest-progress sections; E7.M3 module + alignment register updated.
- **Manual QA:** [`docs/manual-qa/NEO-140.md`](../manual-qa/NEO-140.md).
## Technical approach
### 1. World quest definitions — `factionGateRules`
**DTOs** (`QuestDefinitionsListDtos.cs`):
| Type | JSON | Notes |
|------|------|-------|
| **`QuestFactionGateRuleJson`** | `factionId`, `minStanding` | New grant-line type |
| **`QuestDefinitionJson`** | optional **`factionGateRules`** | `JsonIgnore(WhenWritingNull)`; non-null when `QuestDefRow.FactionGateRules.Count > 0` |
**Mapper** (`QuestDefinitionsWorldApi.cs`):
- When mapping each `QuestDefRow`, if `d.FactionGateRules.Count > 0`, set `FactionGateRules` to mapped list; else leave null (omitted in JSON).
- Preserve rule order from catalog.
**Prototype assertion target:**
```json
{
"id": "prototype_quest_grid_contract",
"factionGateRules": [
{ "factionId": "prototype_faction_grid_operators", "minStanding": 15 }
]
}
```
Non-gated quests (gather intro, operator chain, etc.) omit **`factionGateRules`**.
### 2. Quest progress — `completionRewardSummary.reputationGrants`
**DTOs** (`QuestProgressListDtos.cs`):
| Type | JSON | Notes |
|------|------|-------|
| **`QuestReputationGrantJson`** | `factionId`, `amount` | New line type |
| **`QuestCompletionRewardSummaryJson`** | add **`reputationGrants`** | Required when summary present; empty array allowed |
**Mapper** (`QuestProgressApi.cs`):
- Extend `MapCompletionRewardSummary` to map `deliveryEvent.GrantedReputation``reputationGrants` via new `MapReputationGrants` helper (commit-time order preserved).
- `QuestAcceptApi.MapResponse` unchanged — already calls `QuestProgressApi.MapQuestProgressRow`.
**Prototype assertion targets:**
| Quest | `reputationGrants` when completed |
|-------|----------------------------------|
| **`prototype_quest_gather_intro`** | `[]` |
| **`prototype_quest_operator_chain`** | `[{ factionId: prototype_faction_grid_operators, amount: 15 }]` |
| **`prototype_quest_grid_contract`** | `[{ factionId: prototype_faction_rust_collective, amount: 10 }]` (after hand-in flow lands in tests/Bruno) |
### 3. Docs
- **`server/README.md`**: extend quest-definitions table with **`factionGateRules`**; extend quest-progress **`completionRewardSummary`** table with **`reputationGrants`** source (`GrantedReputation`).
- On ship: update [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) status line and [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M3 row.
### 4. Flow (read paths only)
```mermaid
sequenceDiagram
participant Client
participant WorldAPI as QuestDefinitionsWorldApi
participant ProgressAPI as QuestProgressApi
participant Registry as IQuestDefinitionRegistry
participant Delivery as IRewardDeliveryStore
Client->>WorldAPI: GET /game/world/quest-definitions
WorldAPI->>Registry: GetDefinitionsInIdOrder()
WorldAPI-->>Client: quests[] (+ factionGateRules on gated rows)
Client->>ProgressAPI: GET /game/players/{id}/quest-progress
ProgressAPI->>Delivery: TryGet per completed quest
ProgressAPI-->>Client: quests[] (+ completionRewardSummary.reputationGrants)
```
## Files to add
| Path | Rationale |
|------|-----------|
| `bruno/neon-sprawl-server/quest-progress/Get quest progress after operator chain complete.bru` | Bruno spine asserting operator-chain **`completionRewardSummary.reputationGrants`** (+15 Grid Operators); mirrors gather-intro complete bru pattern |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs` | Add `QuestFactionGateRuleJson`; optional `factionGateRules` on `QuestDefinitionJson` |
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs` | Map `QuestDefRow.FactionGateRules` into world GET JSON |
| `server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs` | Add `QuestReputationGrantJson`; extend `QuestCompletionRewardSummaryJson` |
| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Map `GrantedReputation``reputationGrants` in `MapCompletionRewardSummary` |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs` | Assert grid-contract `factionGateRules`; assert non-gated quests omit property |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Extend operator-chain summary helper (+15 rep); extend equality helper; optional grid-contract rep assertion |
| `bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru` | Assert `prototype_quest_grid_contract.factionGateRules` freeze row |
| `server/README.md` | Document new projection fields on both GET routes |
## Tests
| File | Coverage |
|------|----------|
| `QuestDefinitionsWorldApiTests.cs` | **Add/change:** grid-contract row includes `[{ factionId: prototype_faction_grid_operators, minStanding: 15 }]`; gather intro row has no `factionGateRules` in deserialized model (null / omitted) |
| `QuestProgressApiTests.cs` | **Change:** `AssertOperatorChainCompletionRewardSummary` expects `reputationGrants` +15 Grid Operators; `AssertCompletionRewardSummariesEqual` compares rep lines; gather-intro summary asserts empty `reputationGrants` |
| `bruno/.../quest-definitions/Get quest definitions.bru` | Grid-contract gate rule assertion |
| `bruno/.../quest-progress/Get quest progress after operator chain complete.bru` | **New** — operator-chain complete → GET asserts rep grant line |
Manual verification: run Bruno quest-definitions + new quest-progress bru against local server after operator-chain flow helper.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Existing clients ignore unknown `reputationGrants` field | **Additive field only**`schemaVersion` stays 1; NEO-142 is first consumer | `adopted` |
| Grid-contract completion rep Bruno needs full hand-in flow | ~~**Operator-chain Bruno first** (primary AC); grid-contract rep assertion in integration test using existing quest wiring helpers~~ | `adopted`**done:** integration test + accept bru assert +10 Rust Collective |
| `QuestDefinitionsWorldApiTests` missing grid-contract row coverage today | **Add grid-contract gate assertions** in same test method | `adopted` |

View File

@ -0,0 +1,57 @@
# Code review — NEO-140 (E7M3-08)
**Date:** 2026-06-17
**Scope:** Branch `NEO-140-e7m3-quest-http-rep-gate-projections` — commits `367f894``1dedc5f` vs `main`
**Base:** `main`
**Follow-up:** Suggestions and actionable nits below are **done** (strikethrough + **Done.** / **Addressed.** / **Deferred**).
## Verdict
**Approve with nits**
## Summary
This branch lands **E7M3-08**: additive HTTP projections so clients can read faction gate rules on world quest definitions and reputation grant lines on quest-progress `completionRewardSummary`, without local standing math. Implementation mirrors **NEO-129** / **NEO-115** patterns — optional `factionGateRules` on world GET (omitted when empty), required `reputationGrants` array when a completion summary exists (sourced from `RewardDeliveryEvent.GrantedReputation` at commit time). Shared mappers mean **QuestAcceptApi** embedded rows pick up rep lines automatically. Integration tests extend existing quest API suites; Bruno adds operator-chain completion assertions and extends quest-definitions / gather-intro bru. **821** server tests pass locally. Server-only story; Godot HUD remains **NEO-142**. Low merge risk — read-only projections, schema version stays **1**.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-140-implementation-plan.md` | **Matches** — kickoff decisions, AC checklist, reconciliation section, and file list align with the shipped diff. |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-08 server scope; client counterpart NEO-142 explicitly deferred. |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-08 landed (NEO-140). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated with quest HTTP projection links. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register row edit required beyond module doc / alignment table. |
| `docs/manual-qa/NEO-140.md` | **Matches** — Bruno + curl checklist covers gate rules and rep summary assertions. |
| `server/README.md` | **Matches** — Quest-definitions and quest-progress sections document new fields with NEO-140 cross-links. |
## Blocking issues
None.
## Suggestions
1. ~~**Grid-contract completion rep (+10 Rust Collective) still untested end-to-end.** Plan §prototype targets and open-questions table adopt deferring grid-contract `reputationGrants` Bruno until a hand-in flow helper exists; operator-chain coverage is the primary AC and is solid. When grid-contract completion lands in Bruno or integration helpers, add an assertion for `[{ factionId: prototype_faction_rust_collective, amount: 10 }]` so the third freeze row in the plan table is locked like operator-chain.~~ **Done.** `QuestProgressApiTests.GetQuestProgress_ShouldReturnGridContractReputationGrant_WhenAcceptedAfterOperatorChain`; `Accept grid contract after operator chain.bru` asserts embedded `completionRewardSummary.reputationGrants`.
2. ~~**Optional wire-level omission test for `factionGateRules`.** `QuestDefinitionsWorldApiTests` asserts deserialized `FactionGateRules` is null on non-gated quests; Bruno asserts JSON `undefined` for gather intro and operator chain. A small integration test that reads raw JSON (or `JsonDocument`) and confirms the property key is absent on non-gated rows would close the loop without Bruno — low priority given Bruno coverage.~~ **Done.** `GetQuestDefinitions_ShouldOmitFactionGateRulesJsonProperty_WhenQuestHasNoGates` uses `JsonDocument` to assert key absence on gather intro and operator chain.
## Nits
- ~~Nit: `docs/manual-qa/NEO-140.md` links client follow-up as `[NEO-142](NEO-142.md)` — relative path may not resolve from `docs/manual-qa/`; prefer `NEO-142.md` (same folder) or `../manual-qa/NEO-142.md` from other docs.~~ **Done.** Linear issue link until `NEO-142.md` exists.
- ~~Nit: `Get quest progress after operator chain complete.bru` pre-request performs a quest-progress GET before the named GET (mirrors gather-intro bru idempotency pattern) — fine; seq **12** places it after gather-intro complete (**8**); confirm collection order if Bruno runs fail mid-spine.~~ **Addressed.** Pre-request helpers reset quest/inventory state per bru; seq order does not share mutable spine between these requests.
- Nit: Client fixture JSON in `client/test/quest_progress_client_test.gd` (`_completed_with_reward_summary_json`) omits `reputationGrants` — acceptable until **NEO-142**; Godot client ignores unknown keys today. **Deferred** to NEO-142.
## Verification
```bash
cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln
```
Manual (see `docs/manual-qa/NEO-140.md`):
- `cd server/NeonSprawl.Server && dotnet run`
- Bruno: `quest-definitions/Get quest definitions`
- Bruno: `quest-progress/Get quest progress after gather intro complete`
- Bruno: `quest-progress/Get quest progress after operator chain complete`
- Regression: `faction-standing/Get faction standing after operator chain`, `quest-progress/Accept grid contract after operator chain`

View File

@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using Xunit; using Xunit;
@ -74,5 +75,43 @@ public class QuestDefinitionsWorldApiTests
Assert.Equal("inventory_has_item", terminalObjective.Kind); Assert.Equal("inventory_has_item", terminalObjective.Kind);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, terminalObjective.ItemId); Assert.Equal(PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, terminalObjective.ItemId);
Assert.Equal(1, terminalObjective.Quantity); Assert.Equal(1, terminalObjective.Quantity);
Assert.Null(operatorChain.FactionGateRules);
var gridContract = body.Quests.Single(q => q.Id == PrototypeE7M1QuestCatalogRules.GridContractQuestId);
Assert.NotNull(gridContract.FactionGateRules);
Assert.Single(gridContract.FactionGateRules!);
Assert.Equal(PrototypeE7M3QuestFactionRules.GridContractGateFactionId, gridContract.FactionGateRules![0].FactionId);
Assert.Equal(PrototypeE7M3QuestFactionRules.GridContractMinStanding, gridContract.FactionGateRules![0].MinStanding);
Assert.Null(gatherIntro.FactionGateRules);
}
[Fact]
public async Task GetQuestDefinitions_ShouldOmitFactionGateRulesJsonProperty_WhenQuestHasNoGates()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/world/quest-definitions");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
foreach (var quest in doc.RootElement.GetProperty("quests").EnumerateArray())
{
var id = quest.GetProperty("id").GetString();
if (id is "prototype_quest_gather_intro" or PrototypeE7M1QuestCatalogRules.ChainQuestId)
{
Assert.False(quest.TryGetProperty("factionGateRules", out _));
}
if (id == PrototypeE7M1QuestCatalogRules.GridContractQuestId)
{
Assert.True(quest.TryGetProperty("factionGateRules", out var rules));
Assert.Equal(JsonValueKind.Array, rules.ValueKind);
Assert.Equal(1, rules.GetArrayLength());
}
}
} }
} }

View File

@ -23,6 +23,8 @@ public sealed class QuestProgressApiTests
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId; private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId; private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
private const string RustCollectiveFactionId = "prototype_faction_rust_collective";
private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId; private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId;
private const string ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId; private const string ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
@ -244,6 +246,28 @@ public sealed class QuestProgressApiTests
AssertOperatorChainCompletionRewardSummary(row.CompletionRewardSummary); AssertOperatorChainCompletionRewardSummary(row.CompletionRewardSummary);
} }
[Fact]
public async Task GetQuestProgress_ShouldReturnGridContractReputationGrant_WhenAcceptedAfterOperatorChain()
{
// Arrange — operator-chain kit satisfies grid contract inventory_has_item; accept delivers +10 Rust Collective rep.
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var client = factory.CreateClient();
CompleteOperatorChain(deps);
Assert.True(TryAccept(deps, GridContractQuestId).Success);
// Act
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(body);
var row = Assert.Single(body!.Quests, static r => r.QuestId == GridContractQuestId);
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
AssertGridContractCompletionRewardSummary(row.CompletionRewardSummary);
}
[Fact] [Fact]
public async Task GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet() public async Task GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet()
{ {
@ -291,6 +315,7 @@ public sealed class QuestProgressApiTests
Assert.Single(summary.SkillXpGrants); Assert.Single(summary.SkillXpGrants);
Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId); Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId);
Assert.Equal(25, summary.SkillXpGrants[0].Amount); Assert.Equal(25, summary.SkillXpGrants[0].Amount);
Assert.Empty(summary.ReputationGrants);
} }
private static void AssertOperatorChainCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary) private static void AssertOperatorChainCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary)
@ -302,6 +327,19 @@ public sealed class QuestProgressApiTests
Assert.Single(summary.SkillXpGrants); Assert.Single(summary.SkillXpGrants);
Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId); Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId);
Assert.Equal(50, summary.SkillXpGrants[0].Amount); Assert.Equal(50, summary.SkillXpGrants[0].Amount);
Assert.Single(summary.ReputationGrants);
Assert.Equal(PrototypeE7M3QuestFactionRules.GridContractGateFactionId, summary.ReputationGrants[0].FactionId);
Assert.Equal(15, summary.ReputationGrants[0].Amount);
}
private static void AssertGridContractCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary)
{
Assert.NotNull(summary);
Assert.Empty(summary!.ItemGrants);
Assert.Empty(summary.SkillXpGrants);
Assert.Single(summary.ReputationGrants);
Assert.Equal(RustCollectiveFactionId, summary.ReputationGrants[0].FactionId);
Assert.Equal(10, summary.ReputationGrants[0].Amount);
} }
private static void AssertCompletionRewardSummariesEqual( private static void AssertCompletionRewardSummariesEqual(
@ -323,6 +361,13 @@ public sealed class QuestProgressApiTests
Assert.Equal(first.SkillXpGrants[i].SkillId, second.SkillXpGrants[i].SkillId); Assert.Equal(first.SkillXpGrants[i].SkillId, second.SkillXpGrants[i].SkillId);
Assert.Equal(first.SkillXpGrants[i].Amount, second.SkillXpGrants[i].Amount); Assert.Equal(first.SkillXpGrants[i].Amount, second.SkillXpGrants[i].Amount);
} }
Assert.Equal(first.ReputationGrants.Count, second.ReputationGrants.Count);
for (var i = 0; i < first.ReputationGrants.Count; i++)
{
Assert.Equal(first.ReputationGrants[i].FactionId, second.ReputationGrants[i].FactionId);
Assert.Equal(first.ReputationGrants[i].Amount, second.ReputationGrants[i].Amount);
}
} }
private static void CompleteOperatorChain(QuestWiringTestDependencies deps) private static void CompleteOperatorChain(QuestWiringTestDependencies deps)

View File

@ -29,6 +29,21 @@ public sealed class QuestDefinitionJson
[JsonPropertyName("steps")] [JsonPropertyName("steps")]
public required IReadOnlyList<QuestStepDefinitionJson> Steps { get; init; } public required IReadOnlyList<QuestStepDefinitionJson> Steps { get; init; }
/// <summary>Minimum-standing gates for quest accept; omitted when the quest has no rules (NEO-140).</summary>
[JsonPropertyName("factionGateRules")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<QuestFactionGateRuleJson>? FactionGateRules { get; init; }
}
/// <summary>One faction gate rule in the read-only quest definition projection (NEO-140).</summary>
public sealed class QuestFactionGateRuleJson
{
[JsonPropertyName("factionId")]
public required string FactionId { get; init; }
[JsonPropertyName("minStanding")]
public int MinStanding { get; init; }
} }
/// <summary>One step in the read-only quest definition projection.</summary> /// <summary>One step in the read-only quest definition projection.</summary>

View File

@ -20,6 +20,7 @@ public static class QuestDefinitionsWorldApi
DisplayName = d.DisplayName, DisplayName = d.DisplayName,
PrerequisiteQuestIds = d.PrerequisiteQuestIds, PrerequisiteQuestIds = d.PrerequisiteQuestIds,
Steps = MapSteps(d.Steps), Steps = MapSteps(d.Steps),
FactionGateRules = MapFactionGateRules(d.FactionGateRules),
}); });
} }
@ -34,6 +35,27 @@ public static class QuestDefinitionsWorldApi
return app; return app;
} }
private static List<QuestFactionGateRuleJson>? MapFactionGateRules(IReadOnlyList<FactionGateRuleRow> rules)
{
if (rules.Count == 0)
{
return null;
}
var list = new List<QuestFactionGateRuleJson>(rules.Count);
foreach (var rule in rules)
{
list.Add(
new QuestFactionGateRuleJson
{
FactionId = rule.FactionId,
MinStanding = rule.MinStanding,
});
}
return list;
}
private static List<QuestStepDefinitionJson> MapSteps(IReadOnlyList<QuestStepDefRow> steps) private static List<QuestStepDefinitionJson> MapSteps(IReadOnlyList<QuestStepDefRow> steps)
{ {
var list = new List<QuestStepDefinitionJson>(steps.Count); var list = new List<QuestStepDefinitionJson>(steps.Count);

View File

@ -132,6 +132,7 @@ public static class QuestProgressApi
{ {
ItemGrants = MapItemGrants(deliveryEvent.GrantedItems), ItemGrants = MapItemGrants(deliveryEvent.GrantedItems),
SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp), SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp),
ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation),
}; };
} }
@ -169,6 +170,24 @@ public static class QuestProgressApi
return summary; return summary;
} }
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedReputation"/>.</summary>
private static List<QuestReputationGrantJson> MapReputationGrants(
IReadOnlyList<RewardReputationGrantApplied> grants)
{
var summary = new List<QuestReputationGrantJson>(grants.Count);
foreach (var grant in grants)
{
summary.Add(
new QuestReputationGrantJson
{
FactionId = grant.FactionId,
Amount = grant.Amount,
});
}
return summary;
}
private static QuestProgressRowJson NotStartedRow(string questId) => private static QuestProgressRowJson NotStartedRow(string questId) =>
new() new()
{ {

View File

@ -45,7 +45,7 @@ public sealed class QuestProgressRowJson
public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; } public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; }
} }
/// <summary>Item + skill XP grants applied on first-time quest completion (NEO-129).</summary> /// <summary>Item, skill XP, and reputation grants applied on first-time quest completion (NEO-129, NEO-140).</summary>
public sealed class QuestCompletionRewardSummaryJson public sealed class QuestCompletionRewardSummaryJson
{ {
[JsonPropertyName("itemGrants")] [JsonPropertyName("itemGrants")]
@ -53,6 +53,9 @@ public sealed class QuestCompletionRewardSummaryJson
[JsonPropertyName("skillXpGrants")] [JsonPropertyName("skillXpGrants")]
public required IReadOnlyList<QuestSkillXpGrantJson> SkillXpGrants { get; init; } public required IReadOnlyList<QuestSkillXpGrantJson> SkillXpGrants { get; init; }
[JsonPropertyName("reputationGrants")]
public required IReadOnlyList<QuestReputationGrantJson> ReputationGrants { get; init; }
} }
/// <summary>One item grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary> /// <summary>One item grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary>
@ -74,3 +77,13 @@ public sealed class QuestSkillXpGrantJson
[JsonPropertyName("amount")] [JsonPropertyName("amount")]
public required int Amount { get; init; } public required int Amount { get; init; }
} }
/// <summary>One reputation grant line in <see cref="QuestCompletionRewardSummaryJson"/> (NEO-140).</summary>
public sealed class QuestReputationGrantJson
{
[JsonPropertyName("factionId")]
public required string FactionId { get; init; }
[JsonPropertyName("amount")]
public required int Amount { get; init; }
}

View File

@ -245,7 +245,7 @@ On success, **Information** logs include the resolved quests directory path, dis
## Quest definitions (NEO-115) ## Quest definitions (NEO-115)
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`. **`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
```bash ```bash
curl -sS -i "http://localhost:5253/game/world/quest-definitions" curl -sS -i "http://localhost:5253/game/world/quest-definitions"
@ -310,9 +310,9 @@ Completed rows cannot regress to active without a reset API (none in prototype).
| Field | When present | | Field | When present |
|-------|----------------| |-------|----------------|
| **`completedAt`** | **`status: completed`** | | **`completedAt`** | **`status: completed`** |
| **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`) and **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`). Omitted when not completed or when no delivery record exists. | | **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`), **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`), and **`GrantedReputation`** → **`reputationGrants`** (`factionId`, `amount`). Omitted when not completed or when no delivery record exists. |
Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`** and empty **`itemGrants`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`**, empty **`itemGrants`**, and empty **`reputationGrants`**. Operator chain completion adds **`reputationGrants: [{ factionId: prototype_faction_grid_operators, amount: 15 }]`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); rep projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`, `Get quest progress after operator chain complete.bru`.
Before building the snapshot, the handler best-effort runs **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** — refreshes **`inventory_has_item`** counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: [NEO-119 implementation plan](../../docs/plans/NEO-119-implementation-plan.md). Before building the snapshot, the handler best-effort runs **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** — refreshes **`inventory_has_item`** counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: [NEO-119 implementation plan](../../docs/plans/NEO-119-implementation-plan.md).