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).