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/`.