diff --git a/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru
new file mode 100644
index 0000000..abfc4c8
--- /dev/null
+++ b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru
@@ -0,0 +1,57 @@
+meta {
+ name: GET quest definitions
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{baseUrl}}/game/world/quest-definitions
+ body: none
+ auth: none
+}
+
+tests {
+ test("returns 200 JSON with schema v1 and quests array", function () {
+ expect(res.getStatus()).to.equal(200);
+ expect(res.getHeader("content-type")).to.contain("application/json");
+ const body = res.getBody();
+ expect(body.schemaVersion).to.equal(1);
+ expect(body.quests).to.be.an("array");
+ expect(body.quests.length).to.equal(4);
+ });
+
+ test("quests are ascending by id (ordinal)", function () {
+ const body = res.getBody();
+ const ids = body.quests.map((x) => x.id);
+ const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
+ expect(ids).to.eql(sorted);
+ });
+
+ test("prototype_quest_gather_intro row matches catalog", function () {
+ const body = res.getBody();
+ const row = body.quests.find((x) => x.id === "prototype_quest_gather_intro");
+ expect(row).to.be.an("object");
+ expect(row.displayName).to.equal("Intro: Salvage Run");
+ expect(row.prerequisiteQuestIds).to.eql([]);
+ expect(row.steps).to.have.length(1);
+ expect(row.steps[0].objectives[0]).to.eql({
+ id: "gather_intro_obj_scrap",
+ kind: "gather_item",
+ itemId: "scrap_metal_bulk",
+ quantity: 3,
+ });
+ });
+
+ test("prototype_quest_operator_chain has four steps and terminal token objective", function () {
+ const body = res.getBody();
+ const row = body.quests.find((x) => x.id === "prototype_quest_operator_chain");
+ expect(row).to.be.an("object");
+ expect(row.steps).to.have.length(4);
+ expect(row.steps[3].objectives[0]).to.eql({
+ id: "chain_obj_token",
+ kind: "inventory_has_item",
+ itemId: "contract_handoff_token",
+ quantity: 1,
+ });
+ });
+}
diff --git a/bruno/neon-sprawl-server/quest-definitions/folder.bru b/bruno/neon-sprawl-server/quest-definitions/folder.bru
new file mode 100644
index 0000000..31b1b53
--- /dev/null
+++ b/bruno/neon-sprawl-server/quest-definitions/folder.bru
@@ -0,0 +1,3 @@
+meta {
+ name: quest-definitions
+}
diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs
new file mode 100644
index 0000000..c5157df
--- /dev/null
+++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs
@@ -0,0 +1,78 @@
+using System.Net;
+using System.Net.Http.Json;
+using NeonSprawl.Server.Game.Quests;
+using Xunit;
+
+namespace NeonSprawl.Server.Tests.Game.Quests;
+
+public class QuestDefinitionsWorldApiTests
+{
+ /// Frozen prototype quest ids in ascending ordinal order. Keep in sync with Bruno.
+ public static readonly string[] FrozenQuestIdsInOrdinalOrder =
+ [
+ "prototype_quest_combat_intro",
+ "prototype_quest_gather_intro",
+ "prototype_quest_operator_chain",
+ "prototype_quest_refine_intro",
+ ];
+
+ [Fact]
+ public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFourFrozenQuestsInIdOrder()
+ {
+ // 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);
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(body);
+ Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
+ Assert.NotNull(body.Quests);
+ Assert.Equal(4, body.Quests.Count);
+ Assert.Equal(FrozenQuestIdsInOrdinalOrder, body.Quests.Select(q => q.Id).ToArray());
+
+ var gatherIntro = body.Quests.Single(q => q.Id == "prototype_quest_gather_intro");
+ Assert.Equal("Intro: Salvage Run", gatherIntro.DisplayName);
+ Assert.Empty(gatherIntro.PrerequisiteQuestIds);
+ Assert.Single(gatherIntro.Steps);
+ var gatherStep = gatherIntro.Steps[0];
+ Assert.Equal("gather_intro_step_salvage", gatherStep.Id);
+ Assert.Equal("Gather scrap metal", gatherStep.DisplayName);
+ Assert.Single(gatherStep.Objectives);
+ var gatherObjective = gatherStep.Objectives[0];
+ Assert.Equal("gather_intro_obj_scrap", gatherObjective.Id);
+ Assert.Equal("gather_item", gatherObjective.Kind);
+ Assert.Equal("scrap_metal_bulk", gatherObjective.ItemId);
+ Assert.Equal(3, gatherObjective.Quantity);
+ Assert.Null(gatherObjective.RecipeId);
+ Assert.Null(gatherObjective.EncounterId);
+
+ var refineIntro = body.Quests.Single(q => q.Id == "prototype_quest_refine_intro");
+ Assert.Equal(["prototype_quest_gather_intro"], refineIntro.PrerequisiteQuestIds);
+
+ var combatIntro = body.Quests.Single(q => q.Id == "prototype_quest_combat_intro");
+ Assert.Single(combatIntro.Steps);
+ var combatObjective = combatIntro.Steps[0].Objectives[0];
+ Assert.Equal("encounter_complete", combatObjective.Kind);
+ Assert.Equal("prototype_combat_pocket", combatObjective.EncounterId);
+ Assert.Null(combatObjective.ItemId);
+ Assert.Null(combatObjective.Quantity);
+ Assert.Null(combatObjective.RecipeId);
+
+ var operatorChain = body.Quests.Single(q => q.Id == PrototypeE7M1QuestCatalogRules.ChainQuestId);
+ Assert.Equal(4, operatorChain.Steps.Count);
+ Assert.Equal(
+ [
+ "prototype_quest_gather_intro",
+ "prototype_quest_refine_intro",
+ "prototype_quest_combat_intro",
+ ],
+ operatorChain.PrerequisiteQuestIds);
+ var terminalObjective = operatorChain.Steps[3].Objectives[0];
+ Assert.Equal("inventory_has_item", terminalObjective.Kind);
+ Assert.Equal(PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, terminalObjective.ItemId);
+ Assert.Equal(1, terminalObjective.Quantity);
+ }
+}
diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs
new file mode 100644
index 0000000..a2bcf86
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs
@@ -0,0 +1,71 @@
+using System.Text.Json.Serialization;
+
+namespace NeonSprawl.Server.Game.Quests;
+
+/// JSON body for GET /game/world/quest-definitions (NEO-115).
+public sealed class QuestDefinitionsListResponse
+{
+ public const int CurrentSchemaVersion = 1;
+
+ [JsonPropertyName("schemaVersion")]
+ public int SchemaVersion { get; init; } = CurrentSchemaVersion;
+
+ /// Loaded quests ordered by stable id (ordinal), matching .
+ [JsonPropertyName("quests")]
+ public required IReadOnlyList Quests { get; init; }
+}
+
+/// One row in the read-only quest definition projection.
+public sealed class QuestDefinitionJson
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("displayName")]
+ public required string DisplayName { get; init; }
+
+ [JsonPropertyName("prerequisiteQuestIds")]
+ public required IReadOnlyList PrerequisiteQuestIds { get; init; }
+
+ [JsonPropertyName("steps")]
+ public required IReadOnlyList Steps { get; init; }
+}
+
+/// One step in the read-only quest definition projection.
+public sealed class QuestStepDefinitionJson
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("displayName")]
+ public required string DisplayName { get; init; }
+
+ [JsonPropertyName("objectives")]
+ public required IReadOnlyList Objectives { get; init; }
+}
+
+/// One objective in the read-only quest definition projection (flat mirror of content).
+public sealed class QuestObjectiveDefinitionJson
+{
+ [JsonPropertyName("id")]
+ public required string Id { get; init; }
+
+ [JsonPropertyName("kind")]
+ public required string Kind { get; init; }
+
+ [JsonPropertyName("itemId")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ItemId { get; init; }
+
+ [JsonPropertyName("quantity")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public int? Quantity { get; init; }
+
+ [JsonPropertyName("recipeId")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? RecipeId { get; init; }
+
+ [JsonPropertyName("encounterId")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? EncounterId { get; init; }
+}
diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs
new file mode 100644
index 0000000..e56416a
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs
@@ -0,0 +1,98 @@
+namespace NeonSprawl.Server.Game.Quests;
+
+/// Maps GET /game/world/quest-definitions (NEO-115).
+public static class QuestDefinitionsWorldApi
+{
+ public static WebApplication MapQuestDefinitionsWorldApi(this WebApplication app)
+ {
+ app.MapGet(
+ "/game/world/quest-definitions",
+ (IQuestDefinitionRegistry questRegistry) =>
+ {
+ var defs = questRegistry.GetDefinitionsInIdOrder();
+ var quests = new List(defs.Count);
+ foreach (var d in defs)
+ {
+ quests.Add(
+ new QuestDefinitionJson
+ {
+ Id = d.Id,
+ DisplayName = d.DisplayName,
+ PrerequisiteQuestIds = d.PrerequisiteQuestIds,
+ Steps = MapSteps(d.Steps),
+ });
+ }
+
+ return Results.Json(
+ new QuestDefinitionsListResponse
+ {
+ SchemaVersion = QuestDefinitionsListResponse.CurrentSchemaVersion,
+ Quests = quests,
+ });
+ });
+
+ return app;
+ }
+
+ private static List MapSteps(IReadOnlyList steps)
+ {
+ var list = new List(steps.Count);
+ foreach (var step in steps)
+ {
+ list.Add(
+ new QuestStepDefinitionJson
+ {
+ Id = step.Id,
+ DisplayName = step.DisplayName,
+ Objectives = MapObjectives(step.Objectives),
+ });
+ }
+
+ return list;
+ }
+
+ private static List MapObjectives(IReadOnlyList objectives)
+ {
+ var list = new List(objectives.Count);
+ foreach (var objective in objectives)
+ {
+ list.Add(MapObjective(objective));
+ }
+
+ return list;
+ }
+
+ private static QuestObjectiveDefinitionJson MapObjective(QuestObjectiveDefRow objective) =>
+ objective.Kind switch
+ {
+ "gather_item" or "inventory_has_item" => new QuestObjectiveDefinitionJson
+ {
+ Id = objective.Id,
+ Kind = objective.Kind,
+ ItemId = objective.ItemId,
+ Quantity = objective.Quantity,
+ },
+ "craft_recipe" => new QuestObjectiveDefinitionJson
+ {
+ Id = objective.Id,
+ Kind = objective.Kind,
+ RecipeId = objective.RecipeId,
+ Quantity = objective.Quantity,
+ },
+ "encounter_complete" => new QuestObjectiveDefinitionJson
+ {
+ Id = objective.Id,
+ Kind = objective.Kind,
+ EncounterId = objective.EncounterId,
+ },
+ _ => new QuestObjectiveDefinitionJson
+ {
+ Id = objective.Id,
+ Kind = objective.Kind,
+ ItemId = objective.ItemId,
+ Quantity = objective.Quantity,
+ RecipeId = objective.RecipeId,
+ EncounterId = objective.EncounterId,
+ },
+ };
+}
diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs
index f664013..c50f10f 100644
--- a/server/NeonSprawl.Server/Program.cs
+++ b/server/NeonSprawl.Server/Program.cs
@@ -70,6 +70,7 @@ app.MapNpcRuntimeSnapshotWorldApi();
app.MapPlayerCombatHealthApi();
app.MapResourceNodeDefinitionsWorldApi();
app.MapEncounterDefinitionsWorldApi();
+app.MapQuestDefinitionsWorldApi();
app.MapPlayerInventoryApi();
app.MapPlayerCraftApi();
app.MapSkillProgressionSnapshotApi();
diff --git a/server/README.md b/server/README.md
index a9b85e7..7c5917d 100644
--- a/server/README.md
+++ b/server/README.md
@@ -148,6 +148,14 @@ On startup the host loads every **`*_quests.json`** under the quests directory *
On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes.
+## 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/`.
+
+```bash
+curl -sS -i "http://localhost:5253/game/world/quest-definitions"
+```
+
## Encounter definitions (NEO-103)
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.