From 367f894b34b8f128791821121065ba47a05383a2 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 19:05:54 -0400 Subject: [PATCH 1/5] NEO-140: add E7M3-08 quest HTTP projection kickoff plan Document factionGateRules world GET and completionRewardSummary reputationGrants scope before implementation. --- docs/plans/NEO-140-implementation-plan.md | 171 ++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/plans/NEO-140-implementation-plan.md diff --git a/docs/plans/NEO-140-implementation-plan.md b/docs/plans/NEO-140-implementation-plan.md new file mode 100644 index 0000000..0b7036e --- /dev/null +++ b/docs/plans/NEO-140-implementation-plan.md @@ -0,0 +1,171 @@ +# 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 + +- [ ] Gated quest definition includes **`factionGateRules`** in world GET. +- [ ] Completed quest rows include rep lines in **`completionRewardSummary`** when delivered. +- [ ] `dotnet test` covers grid-contract gate projection and operator-chain rep summary. +- [ ] Bruno asserts grid-contract **`factionGateRules`** and operator-chain **`reputationGrants`**. + +## 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` | +| `QuestDefinitionsWorldApiTests` missing grid-contract row coverage today | **Add grid-contract gate assertions** in same test method | `adopted` | From 2b4a0d1e3470fbd02f7c365d925df09fc40bd79e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 19:08:37 -0400 Subject: [PATCH 2/5] NEO-140: project faction gates and rep grants on quest HTTP GETs World quest-definitions expose optional factionGateRules; quest-progress completionRewardSummary maps GrantedReputation to reputationGrants. --- .../Get quest definitions.bru | 11 +++ ...t progress after gather intro complete.bru | 1 + ...progress after operator chain complete.bru | 67 +++++++++++++++++++ .../Quests/QuestDefinitionsWorldApiTests.cs | 8 +++ .../Game/Quests/QuestProgressApiTests.cs | 11 +++ .../Game/Quests/QuestDefinitionsListDtos.cs | 15 +++++ .../Game/Quests/QuestDefinitionsWorldApi.cs | 22 ++++++ .../Game/Quests/QuestProgressApi.cs | 19 ++++++ .../Game/Quests/QuestProgressListDtos.cs | 15 ++++- server/README.md | 6 +- 10 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 bruno/neon-sprawl-server/quest-progress/Get quest progress after operator chain complete.bru diff --git a/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru index 676509e..0ae18ee 100644 --- a/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru +++ b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru @@ -68,5 +68,16 @@ tests { itemId: "survey_drone_kit", 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); }); } diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru index c7aff9b..89820ea 100644 --- a/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru @@ -139,6 +139,7 @@ tests { expect(row.completionRewardSummary.skillXpGrants).to.eql([ { skillId: "salvage", amount: 25 }, ]); + expect(row.completionRewardSummary.reputationGrants).to.eql([]); }); test("second GET completionRewardSummary matches first GET", function () { diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress after operator chain complete.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress after operator chain complete.bru new file mode 100644 index 0000000..1770a43 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress after operator chain complete.bru @@ -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); + }); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs index 40ee9ec..b964991 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs @@ -74,5 +74,13 @@ public class QuestDefinitionsWorldApiTests Assert.Equal("inventory_has_item", terminalObjective.Kind); Assert.Equal(PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, terminalObjective.ItemId); 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); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs index b5eeca1..b5125a8 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -291,6 +291,7 @@ public sealed class QuestProgressApiTests Assert.Single(summary.SkillXpGrants); Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId); Assert.Equal(25, summary.SkillXpGrants[0].Amount); + Assert.Empty(summary.ReputationGrants); } private static void AssertOperatorChainCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary) @@ -302,6 +303,9 @@ public sealed class QuestProgressApiTests Assert.Single(summary.SkillXpGrants); Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId); 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 AssertCompletionRewardSummariesEqual( @@ -323,6 +327,13 @@ public sealed class QuestProgressApiTests Assert.Equal(first.SkillXpGrants[i].SkillId, second.SkillXpGrants[i].SkillId); 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) diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs index a2bcf86..b67b559 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs @@ -29,6 +29,21 @@ public sealed class QuestDefinitionJson [JsonPropertyName("steps")] public required IReadOnlyList Steps { get; init; } + + /// Minimum-standing gates for quest accept; omitted when the quest has no rules (NEO-140). + [JsonPropertyName("factionGateRules")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? FactionGateRules { get; init; } +} + +/// One faction gate rule in the read-only quest definition projection (NEO-140). +public sealed class QuestFactionGateRuleJson +{ + [JsonPropertyName("factionId")] + public required string FactionId { get; init; } + + [JsonPropertyName("minStanding")] + public int MinStanding { get; init; } } /// One step in the read-only quest definition projection. diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs index e56416a..d1a196e 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs @@ -20,6 +20,7 @@ public static class QuestDefinitionsWorldApi DisplayName = d.DisplayName, PrerequisiteQuestIds = d.PrerequisiteQuestIds, Steps = MapSteps(d.Steps), + FactionGateRules = MapFactionGateRules(d.FactionGateRules), }); } @@ -34,6 +35,27 @@ public static class QuestDefinitionsWorldApi return app; } + private static List? MapFactionGateRules(IReadOnlyList rules) + { + if (rules.Count == 0) + { + return null; + } + + var list = new List(rules.Count); + foreach (var rule in rules) + { + list.Add( + new QuestFactionGateRuleJson + { + FactionId = rule.FactionId, + MinStanding = rule.MinStanding, + }); + } + + return list; + } + private static List MapSteps(IReadOnlyList steps) { var list = new List(steps.Count); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs index 3463dad..bd35646 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs @@ -132,6 +132,7 @@ public static class QuestProgressApi { ItemGrants = MapItemGrants(deliveryEvent.GrantedItems), SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp), + ReputationGrants = MapReputationGrants(deliveryEvent.GrantedReputation), }; } @@ -169,6 +170,24 @@ public static class QuestProgressApi return summary; } + /// Preserves commit-time grant order from . + private static List MapReputationGrants( + IReadOnlyList grants) + { + var summary = new List(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) => new() { diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs index 7dc0439..6434f8a 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs @@ -45,7 +45,7 @@ public sealed class QuestProgressRowJson public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; } } -/// Item + skill XP grants applied on first-time quest completion (NEO-129). +/// Item, skill XP, and reputation grants applied on first-time quest completion (NEO-129, NEO-140). public sealed class QuestCompletionRewardSummaryJson { [JsonPropertyName("itemGrants")] @@ -53,6 +53,9 @@ public sealed class QuestCompletionRewardSummaryJson [JsonPropertyName("skillXpGrants")] public required IReadOnlyList SkillXpGrants { get; init; } + + [JsonPropertyName("reputationGrants")] + public required IReadOnlyList ReputationGrants { get; init; } } /// One item grant line in . @@ -74,3 +77,13 @@ public sealed class QuestSkillXpGrantJson [JsonPropertyName("amount")] public required int Amount { get; init; } } + +/// One reputation grant line in (NEO-140). +public sealed class QuestReputationGrantJson +{ + [JsonPropertyName("factionId")] + public required string FactionId { get; init; } + + [JsonPropertyName("amount")] + public required int Amount { get; init; } +} diff --git a/server/README.md b/server/README.md index a7c416d..3d64a37 100644 --- a/server/README.md +++ b/server/README.md @@ -245,7 +245,7 @@ On success, **Information** logs include the resolved quests directory path, dis ## 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 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 | |-------|----------------| | **`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). From 1dedc5fc99e6748963503665ab64634f2c21cc2c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 19:08:38 -0400 Subject: [PATCH 3/5] NEO-140: reconcile plan, manual QA, and E7.M3 module alignment Mark acceptance criteria done; document Bruno/curl verification checklist. --- .../modules/E7_M3_FactionReputationLedger.md | 2 +- ...umentation_and_implementation_alignment.md | 2 +- docs/manual-qa/NEO-140.md | 40 +++++++++++++++++++ docs/plans/NEO-140-implementation-plan.md | 18 +++++++-- 4 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 docs/manual-qa/NEO-140.md diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index 729439b..113c27d 100644 --- a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md +++ b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md @@ -7,7 +7,7 @@ | **Module ID** | E7.M3 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **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) | ## Purpose diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index c7983d4..a5767b9 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -61,7 +61,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | -| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08+** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | +| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08 quest HTTP projections landed ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)):** world GET **`factionGateRules`** + quest-progress **`completionRewardSummary.reputationGrants`**; Bruno quest-definitions + operator-chain progress bru ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115), [Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119). **E7M3-09+** [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [NEO-140 plan](../../plans/NEO-140-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | --- diff --git a/docs/manual-qa/NEO-140.md b/docs/manual-qa/NEO-140.md new file mode 100644 index 0000000..dfaa48c --- /dev/null +++ b/docs/manual-qa/NEO-140.md @@ -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`** still accepts grid contract when rep gate met. +- [ ] NEO-129 gather-intro **`completionRewardSummary`** shape unchanged except new empty **`reputationGrants`** array. + +**Client HUD verification** deferred to [NEO-142](NEO-142.md) — this story is server HTTP projection only. diff --git a/docs/plans/NEO-140-implementation-plan.md b/docs/plans/NEO-140-implementation-plan.md index 0b7036e..ab45480 100644 --- a/docs/plans/NEO-140-implementation-plan.md +++ b/docs/plans/NEO-140-implementation-plan.md @@ -52,10 +52,20 @@ Client can see gate requirements and completion rep summary without local math. ## Acceptance criteria checklist -- [ ] Gated quest definition includes **`factionGateRules`** in world GET. -- [ ] Completed quest rows include rep lines in **`completionRewardSummary`** when delivered. -- [ ] `dotnet test` covers grid-contract gate projection and operator-chain rep summary. -- [ ] Bruno asserts grid-contract **`factionGateRules`** and operator-chain **`reputationGrants`**. +- [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; `QuestProgressApiTests` operator-chain +15 rep and gather-intro empty rep; **821** 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 From 3ba2fd100c59681dd7f2d2a2b3c5be7614159787 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 19:11:03 -0400 Subject: [PATCH 4/5] NEO-140: add code review for quest HTTP rep + gate projections --- docs/reviews/2026-06-17-NEO-140.md | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/reviews/2026-06-17-NEO-140.md diff --git a/docs/reviews/2026-06-17-NEO-140.md b/docs/reviews/2026-06-17-NEO-140.md new file mode 100644 index 0000000..9b79659 --- /dev/null +++ b/docs/reviews/2026-06-17-NEO-140.md @@ -0,0 +1,55 @@ +# 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` + +## 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. + +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. + +## 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. +- 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. +- 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. + +## 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` From 7011a56755035f38dfdccd58cdfec9adf21c4b7a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 17 Jun 2026 19:12:33 -0400 Subject: [PATCH 5/5] =?UTF-8?q?NEO-140:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20grid-contract=20rep=20and=20wire-level=20gate=20tes?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add integration coverage for +10 Rust Collective reputationGrants, JsonDocument omission test for factionGateRules, extend accept Bruno, fix manual QA link, strike through review findings. --- ...ept grid contract after operator chain.bru | 6 ++++ docs/manual-qa/NEO-140.md | 4 +-- docs/plans/NEO-140-implementation-plan.md | 4 +-- docs/reviews/2026-06-17-NEO-140.md | 12 ++++--- .../Quests/QuestDefinitionsWorldApiTests.cs | 31 +++++++++++++++++ .../Game/Quests/QuestProgressApiTests.cs | 34 +++++++++++++++++++ 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru b/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru index 34ef6fb..1a7f344 100644 --- a/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru +++ b/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru @@ -38,5 +38,11 @@ tests { expect(body.quest).to.be.an("object"); expect(body.quest.questId).to.equal("prototype_quest_grid_contract"); 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 }, + ]); }); } diff --git a/docs/manual-qa/NEO-140.md b/docs/manual-qa/NEO-140.md index dfaa48c..6421cce 100644 --- a/docs/manual-qa/NEO-140.md +++ b/docs/manual-qa/NEO-140.md @@ -34,7 +34,7 @@ ## 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`** still accepts grid contract when rep gate met. +- [ ] 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](NEO-142.md) — this story is server HTTP projection only. +**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. diff --git a/docs/plans/NEO-140-implementation-plan.md b/docs/plans/NEO-140-implementation-plan.md index ab45480..641d413 100644 --- a/docs/plans/NEO-140-implementation-plan.md +++ b/docs/plans/NEO-140-implementation-plan.md @@ -62,7 +62,7 @@ Client can see gate requirements and completion rep summary without local math. - **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; `QuestProgressApiTests` operator-chain +15 rep and gather-intro empty rep; **821** tests green. +- **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). @@ -177,5 +177,5 @@ Manual verification: run Bruno quest-definitions + new quest-progress bru agains | 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` | +| 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` | diff --git a/docs/reviews/2026-06-17-NEO-140.md b/docs/reviews/2026-06-17-NEO-140.md index 9b79659..c9f38d4 100644 --- a/docs/reviews/2026-06-17-NEO-140.md +++ b/docs/reviews/2026-06-17-NEO-140.md @@ -4,6 +4,8 @@ **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** @@ -30,15 +32,15 @@ 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. +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. +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. -- 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. -- 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. +- ~~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 diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs index b964991..a50c853 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using NeonSprawl.Server.Game.Quests; using Xunit; @@ -83,4 +84,34 @@ public class QuestDefinitionsWorldApiTests 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()); + } + } + } } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs index b5125a8..ddf974b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -23,6 +23,8 @@ public sealed class QuestProgressApiTests private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId; private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId; 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 ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId; @@ -244,6 +246,28 @@ public sealed class QuestProgressApiTests 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(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == GridContractQuestId); + Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); + AssertGridContractCompletionRewardSummary(row.CompletionRewardSummary); + } + [Fact] public async Task GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet() { @@ -308,6 +332,16 @@ public sealed class QuestProgressApiTests 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( QuestCompletionRewardSummaryJson? first, QuestCompletionRewardSummaryJson? second)