From b841b64581f26aefdfb62e5d0ef35818f22f3a04 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 22:33:43 -0400 Subject: [PATCH] NEO-113: add fail-fast quest catalog load and loader tests. --- .../Health after quest catalog load.bru | 25 + .../quest-catalog/folder.bru | 3 + .../Game/Quests/QuestCatalogTestPaths.cs | 29 + .../QuestDefinitionCatalogLoaderTests.cs | 565 ++++++++++++++++++ .../InMemoryWebApplicationFactory.cs | 5 + .../Quests/PrototypeE7M1QuestCatalogRules.cs | 182 ++++++ .../Game/Quests/QuestCatalogPathResolution.cs | 96 +++ ...QuestCatalogServiceCollectionExtensions.cs | 63 ++ .../Game/Quests/QuestDefRow.cs | 17 + .../Game/Quests/QuestDefinitionCatalog.cs | 25 + .../Quests/QuestDefinitionCatalogLoader.cs | 386 ++++++++++++ .../Game/Quests/QuestObjectiveDefRow.cs | 23 + .../Game/Quests/QuestStepDefRow.cs | 14 + .../Game/Skills/ContentPathsOptions.cs | 24 + server/NeonSprawl.Server/Program.cs | 3 + server/NeonSprawl.Server/appsettings.json | 6 +- server/README.md | 15 + 17 files changed, 1480 insertions(+), 1 deletion(-) create mode 100644 bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru create mode 100644 bruno/neon-sprawl-server/quest-catalog/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs diff --git a/bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru b/bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru new file mode 100644 index 0000000..a9ca7c8 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (quest catalog boot NEO-113) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-113 loads content/quests/*_quests.json at startup (fail-fast). No quest HTTP API in this story — use this request to confirm the host started after catalog validation. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/bruno/neon-sprawl-server/quest-catalog/folder.bru b/bruno/neon-sprawl-server/quest-catalog/folder.bru new file mode 100644 index 0000000..3683531 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: quest-catalog +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs new file mode 100644 index 0000000..043ab31 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs @@ -0,0 +1,29 @@ +using NeonSprawl.Server.Game.Quests; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +internal static class QuestCatalogTestPaths +{ + internal static string DiscoverRepoQuestsDirectory() => + QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoQuestDefSchemaPath() => + QuestCatalogPathResolution.ResolveQuestDefSchemaPath( + DiscoverRepoQuestsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); + + internal static string DiscoverRepoQuestStepDefSchemaPath() => + QuestCatalogPathResolution.ResolveQuestStepDefSchemaPath( + DiscoverRepoQuestsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); + + internal static string DiscoverRepoQuestObjectiveDefSchemaPath() => + QuestCatalogPathResolution.ResolveQuestObjectiveDefSchemaPath( + DiscoverRepoQuestsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..2737484 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs @@ -0,0 +1,565 @@ +using System.Collections.Frozen; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public class QuestDefinitionCatalogLoaderTests +{ + private static readonly FrozenSet KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds; + + private static readonly FrozenSet KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds; + + private static readonly FrozenSet KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds; + + private const string ValidPrototypeCatalogJson = + """ + { + "schemaVersion": 1, + "quests": [ + { + "id": "prototype_quest_gather_intro", + "displayName": "Intro: Salvage Run", + "prerequisiteQuestIds": [], + "steps": [ + { + "id": "gather_intro_step_salvage", + "displayName": "Gather scrap metal", + "objectives": [ + { + "id": "gather_intro_obj_scrap", + "kind": "gather_item", + "itemId": "scrap_metal_bulk", + "quantity": 3 + } + ] + } + ] + }, + { + "id": "prototype_quest_refine_intro", + "displayName": "Intro: Refine Stock", + "prerequisiteQuestIds": ["prototype_quest_gather_intro"], + "steps": [ + { + "id": "refine_intro_step_craft", + "displayName": "Refine scrap", + "objectives": [ + { + "id": "refine_intro_obj_recipe", + "kind": "craft_recipe", + "recipeId": "refine_scrap_standard", + "quantity": 1 + } + ] + } + ] + }, + { + "id": "prototype_quest_combat_intro", + "displayName": "Intro: Clear the Pocket", + "prerequisiteQuestIds": [], + "steps": [ + { + "id": "combat_intro_step_encounter", + "displayName": "Clear the combat pocket", + "objectives": [ + { + "id": "combat_intro_obj_encounter", + "kind": "encounter_complete", + "encounterId": "prototype_combat_pocket" + } + ] + } + ] + }, + { + "id": "prototype_quest_operator_chain", + "displayName": "Operator Chain", + "prerequisiteQuestIds": [ + "prototype_quest_gather_intro", + "prototype_quest_refine_intro", + "prototype_quest_combat_intro" + ], + "steps": [ + { + "id": "chain_step_gather", + "displayName": "Bulk salvage", + "objectives": [ + { + "id": "chain_obj_gather", + "kind": "gather_item", + "itemId": "scrap_metal_bulk", + "quantity": 5 + } + ] + }, + { + "id": "chain_step_refine", + "displayName": "Refine stock", + "objectives": [ + { + "id": "chain_obj_refine", + "kind": "craft_recipe", + "recipeId": "refine_scrap_standard", + "quantity": 1 + } + ] + }, + { + "id": "chain_step_stim", + "displayName": "Craft field stim", + "objectives": [ + { + "id": "chain_obj_stim", + "kind": "craft_recipe", + "recipeId": "make_field_stim_mk0", + "quantity": 1 + } + ] + }, + { + "id": "chain_step_token", + "displayName": "Hand off contract token", + "objectives": [ + { + "id": "chain_obj_token", + "kind": "inventory_has_item", + "itemId": "contract_handoff_token", + "quantity": 1 + } + ] + } + ] + } + ] + } + """; + + private static (string Root, string QuestsDir, string DefSchemaPath, string StepSchemaPath, string ObjectiveSchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-questcat-"); + var questsDir = Path.Combine(root.FullName, "content", "quests"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(questsDir); + Directory.CreateDirectory(schemaDir); + var defSchemaPath = Path.Combine(schemaDir, "quest-def.schema.json"); + var stepSchemaPath = Path.Combine(schemaDir, "quest-step-def.schema.json"); + var objectiveSchemaPath = Path.Combine(schemaDir, "quest-objective-def.schema.json"); + File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(), defSchemaPath, overwrite: true); + File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(), stepSchemaPath, overwrite: true); + File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(), objectiveSchemaPath, overwrite: true); + return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); + } + + private static void WriteCatalog(string questsDir, string catalogJson) => + File.WriteAllText(Path.Combine(questsDir, "prototype_quests.json"), catalogJson, Encoding.UTF8); + + private static JsonObject GetQuestRow(JsonObject catalogRoot, string questId) + { + var quests = catalogRoot["quests"] as JsonArray + ?? throw new InvalidOperationException("expected quests array"); + foreach (var node in quests) + { + if (node is JsonObject row && row["id"]?.GetValue() == questId) + return row; + } + + throw new InvalidOperationException($"quest id not found: {questId}"); + } + + private static QuestDefinitionCatalog LoadCatalog( + string questsDir, + string defSchemaPath, + string stepSchemaPath, + string objectiveSchemaPath) => + QuestDefinitionCatalogLoader.Load( + questsDir, + defSchemaPath, + stepSchemaPath, + objectiveSchemaPath, + KnownItemIds, + KnownRecipeIds, + KnownEncounterIds, + NullLogger.Instance); + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile() + { + // Arrange + var questsDir = QuestCatalogTestPaths.DiscoverRepoQuestsDirectory(); + var defSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(); + var stepSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(); + var objectiveSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(); + // Act + var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); + // Assert + Assert.Equal(4, catalog.DistinctQuestCount); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + WriteCatalog(questsDir, ValidPrototypeCatalogJson); + // Act + var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); + // Assert + Assert.Equal(4, catalog.DistinctQuestCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); + Assert.NotNull(gather); + Assert.Equal("Intro: Salvage Run", gather!.DisplayName); + Assert.Empty(gather.PrerequisiteQuestIds); + Assert.Single(gather.Steps); + Assert.Equal("gather_item", gather.Steps[0].Objectives[0].Kind); + Assert.True(catalog.TryGetQuest("prototype_quest_operator_chain", out var chain)); + Assert.NotNull(chain); + Assert.Equal(4, chain!.Steps.Count); + Assert.Equal("inventory_has_item", chain.Steps[^1].Objectives[0].Kind); + Assert.Equal("contract_handoff_token", chain.Steps[^1].Objectives[0].ItemId); + } + + [Fact] + public void Load_ShouldThrow_WhenQuestsIsNotArray() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(questsDir, "bad_quests.json"), + """{"schemaVersion": 1, "quests": "nope"}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_quests.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'quests' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(questsDir, "bad_quests.json"), + """{"schemaVersion": 2, "quests": []}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateQuestIdAcrossFiles() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(questsDir, "extra_quests.json"), + """ + { + "schemaVersion": 1, + "quests": [ + { + "id": "prototype_quest_gather_intro", + "displayName": "Duplicate", + "prerequisiteQuestIds": [], + "steps": [ + { + "id": "dup_step", + "displayName": "Dup", + "objectives": [ + { + "id": "dup_obj", + "kind": "gather_item", + "itemId": "scrap_metal_bulk", + "quantity": 1 + } + ] + } + ] + } + ] + } + """, + Encoding.UTF8); + WriteCatalog(questsDir, ValidPrototypeCatalogJson); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate quest id 'prototype_quest_gather_intro'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateStepIdWithinQuest() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var gather = GetQuestRow(root, "prototype_quest_gather_intro"); + var steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + steps.Add(steps[0]!.DeepClone()); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate step id 'gather_intro_step_salvage'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateObjectiveIdWithinQuest() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var firstStep = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = firstStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + objectives[0]!["id"] = "chain_obj_refine"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate objective id 'chain_obj_refine'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenMissingFrozenQuestId() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var quests = root["quests"] as JsonArray ?? throw new InvalidOperationException("expected quests"); + quests.RemoveAt(3); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E7M1 expects exactly quest ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownPrerequisiteQuestId() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var refine = GetQuestRow(root, "prototype_quest_refine_intro"); + refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_missing"); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prerequisiteQuestIds references unknown quest id", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenCyclicPrerequisiteQuestIds() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var gather = GetQuestRow(root, "prototype_quest_gather_intro"); + gather["prerequisiteQuestIds"] = new JsonArray("prototype_quest_refine_intro"); + var refine = GetQuestRow(root, "prototype_quest_refine_intro"); + refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_gather_intro"); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("cyclic prerequisiteQuestIds", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownItemIdInObjective() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var gather = GetQuestRow(root, "prototype_quest_gather_intro"); + var steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["itemId"] = "unknown_item_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("itemId 'unknown_item_id'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownRecipeIdInObjective() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var refine = GetQuestRow(root, "prototype_quest_refine_intro"); + var steps = refine["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["recipeId"] = "unknown_recipe_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("recipeId 'unknown_recipe_id'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownEncounterIdInObjective() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var combat = GetQuestRow(root, "prototype_quest_combat_intro"); + var steps = combat["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["encounterId"] = "unknown_encounter_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("encounterId 'unknown_encounter_id'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenChainTerminalObjectiveIsWrongKind() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["kind"] = "craft_recipe"; + obj["recipeId"] = "refine_scrap_standard"; + obj["quantity"] = 1; + obj.Remove("itemId"); + obj.Remove("encounterId"); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("terminal objective kind must be 'inventory_has_item'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenChainTerminalItemIsWrong() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["itemId"] = "scrap_metal_bulk"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("terminal itemId must be 'contract_handoff_token'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenMissingSchemaFile() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + WriteCatalog(questsDir, ValidPrototypeCatalogJson); + File.Delete(defSchemaPath); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Host_ShouldResolveQuestCatalogFromDi() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/health"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var questCatalog = factory.Services.GetRequiredService(); + Assert.Equal(4, questCatalog.DistinctQuestCount); + Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); + Assert.NotNull(gather); + Assert.Equal("Intro: Salvage Run", gather!.DisplayName); + } + + [Fact] + public void Host_ShouldFailStartup_WhenQuestsDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-quests-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + try + { + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:QuestsDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("Quest catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(badDir)) + Directory.Delete(badDir); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 23560bf..d7f3cf7 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -12,6 +12,7 @@ using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Skills; @@ -58,6 +59,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs new file mode 100644 index 0000000..d76ac3c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs @@ -0,0 +1,182 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Quests; + +/// +/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M1_QUEST_IDS, +/// PROTOTYPE_E7M1_CHAIN_QUEST_ID, PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID, +/// and _prototype_e7m1_* gate functions. +/// +public static class PrototypeE7M1QuestCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_QUEST_IDS. + public static readonly FrozenSet ExpectedQuestIds = FrozenSet.ToFrozenSet( + [ + "prototype_quest_gather_intro", + "prototype_quest_refine_intro", + "prototype_quest_combat_intro", + "prototype_quest_operator_chain", + ], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_QUEST_ID. + public const string ChainQuestId = "prototype_quest_operator_chain"; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID. + public const string ChainTerminalItemId = "contract_handoff_token"; + + /// Returns a human-readable error if the E7M1 quest contract fails, otherwise . + public static string? TryGetE7M1GateError(IReadOnlyDictionary questIdToSourceFile) + { + var ids = questIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedQuestIds)) + { + return + "error: prototype E7M1 expects exactly quest ids " + + $"[{string.Join(", ", ExpectedQuestIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error when prerequisite ids are unknown or cyclic, otherwise . + public static string? TryGetPrerequisiteGateError(IReadOnlyDictionary rowsById) + { + var known = rowsById.Keys.ToFrozenSet(StringComparer.Ordinal); + foreach (var (qid, row) in rowsById) + { + foreach (var pid in row.PrerequisiteQuestIds) + { + if (!known.Contains(pid)) + { + return + $"error: quest '{qid}': prerequisiteQuestIds references unknown quest id '{pid}'"; + } + } + } + + var visiting = new HashSet(StringComparer.Ordinal); + var visited = new HashSet(StringComparer.Ordinal); + + string? Dfs(string node) + { + if (visiting.Contains(node)) + return $"error: cyclic prerequisiteQuestIds detected involving quest '{node}'"; + + if (visited.Contains(node)) + return null; + + visiting.Add(node); + if (rowsById.TryGetValue(node, out var row)) + { + foreach (var pid in row.PrerequisiteQuestIds) + { + var err = Dfs(pid); + if (err is not null) + return err; + } + } + + visiting.Remove(node); + visited.Add(node); + return null; + } + + foreach (var qid in known) + { + var err = Dfs(qid); + if (err is not null) + return err; + } + + return null; + } + + /// Returns a human-readable error when objective catalog cross-refs fail, otherwise . + public static string? TryGetCrossRefError( + IReadOnlyDictionary rowsById, + IReadOnlySet knownItemIds, + IReadOnlySet knownRecipeIds, + IReadOnlySet knownEncounterIds) + { + foreach (var (qid, row) in rowsById) + { + for (var si = 0; si < row.Steps.Count; si++) + { + var step = row.Steps[si]; + for (var oi = 0; oi < step.Objectives.Count; oi++) + { + var obj = step.Objectives[oi]; + if (obj.ItemId is not null && + obj.Kind is "gather_item" or "inventory_has_item" && + !knownItemIds.Contains(obj.ItemId)) + { + return + $"error: quest '{qid}' steps[{si}] objectives[{oi}]: itemId '{obj.ItemId}' " + + "is not in the frozen prototype item catalog"; + } + + if (obj.RecipeId is not null && + obj.Kind == "craft_recipe" && + !knownRecipeIds.Contains(obj.RecipeId)) + { + return + $"error: quest '{qid}' steps[{si}] objectives[{oi}]: recipeId '{obj.RecipeId}' " + + "is not in the frozen prototype recipe catalog"; + } + + if (obj.EncounterId is not null && + obj.Kind == "encounter_complete" && + !knownEncounterIds.Contains(obj.EncounterId)) + { + return + $"error: quest '{qid}' steps[{si}] objectives[{oi}]: encounterId '{obj.EncounterId}' " + + "is not in the frozen prototype encounter catalog"; + } + } + } + } + + return null; + } + + /// Returns a human-readable error when the operator chain terminal step fails, otherwise . + public static string? TryGetChainTerminalGateError(IReadOnlyDictionary rowsById) + { + if (!rowsById.TryGetValue(ChainQuestId, out var chain)) + return $"error: missing chain quest '{ChainQuestId}'"; + + if (chain.Steps.Count == 0) + return $"error: chain quest '{ChainQuestId}' must have at least one step"; + + var lastStep = chain.Steps[^1]; + if (lastStep.Objectives.Count != 1) + { + return + $"error: chain quest '{ChainQuestId}' terminal step must have exactly one objective"; + } + + var obj = lastStep.Objectives[0]; + if (obj.Kind != "inventory_has_item") + { + return + $"error: chain quest '{ChainQuestId}' terminal objective kind must be 'inventory_has_item'"; + } + + if (obj.ItemId != ChainTerminalItemId) + { + return + $"error: chain quest '{ChainQuestId}' terminal itemId must be '{ChainTerminalItemId}', got '{obj.ItemId}'"; + } + + if (obj.Quantity != 1) + { + return + $"error: chain quest '{ChainQuestId}' terminal quantity must be 1, got {obj.Quantity}"; + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs new file mode 100644 index 0000000..96cec83 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs @@ -0,0 +1,96 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Resolves quest catalog paths for local dev, tests, and container layouts (NEO-113). +public static class QuestCatalogPathResolution +{ + /// Walks and parents for an existing content/quests directory. + public static string? TryDiscoverQuestsDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "quests"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the quests catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveQuestsDirectory(string? configuredQuestsDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredQuestsDirectory)) + { + var discovered = TryDiscoverQuestsDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:QuestsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/quests'). " + + "Set Content:QuestsDirectory in configuration or environment (e.g. Content__QuestsDirectory)."); + } + + var trimmed = configuredQuestsDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single quest row (Draft 2020-12). + public static string ResolveQuestDefSchemaPath( + string questsDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-def.schema.json")); + } + + /// Resolves JSON Schema path for a quest step row. + public static string ResolveQuestStepDefSchemaPath( + string questsDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-step-def.schema.json")); + } + + /// Resolves JSON Schema path for a quest objective row. + public static string ResolveQuestObjectiveDefSchemaPath( + string questsDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-objective-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..10e3be7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Quests; + +/// DI registration for the fail-fast quest catalog (NEO-113). +public static class QuestCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddQuestDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var itemCatalog = sp.GetRequiredService(); + var recipeCatalog = sp.GetRequiredService(); + var encounterCatalog = sp.GetRequiredService(); + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog"); + + var questsDir = QuestCatalogPathResolution.ResolveQuestsDirectory( + opts.QuestsDirectory, + hostEnv.ContentRootPath); + var questDefSchemaPath = QuestCatalogPathResolution.ResolveQuestDefSchemaPath( + questsDir, + opts.QuestDefSchemaPath, + hostEnv.ContentRootPath); + var questStepDefSchemaPath = QuestCatalogPathResolution.ResolveQuestStepDefSchemaPath( + questsDir, + opts.QuestStepDefSchemaPath, + hostEnv.ContentRootPath); + var questObjectiveDefSchemaPath = QuestCatalogPathResolution.ResolveQuestObjectiveDefSchemaPath( + questsDir, + opts.QuestObjectiveDefSchemaPath, + hostEnv.ContentRootPath); + + var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + + return QuestDefinitionCatalogLoader.Load( + questsDir, + questDefSchemaPath, + questStepDefSchemaPath, + questObjectiveDefSchemaPath, + knownItemIds, + knownRecipeIds, + knownEncounterIds, + logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs new file mode 100644 index 0000000..9456b65 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs @@ -0,0 +1,17 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Load-time quest row (NEO-113). +public sealed class QuestDefRow( + string id, + string displayName, + IReadOnlyList prerequisiteQuestIds, + IReadOnlyList steps) +{ + public string Id { get; } = id; + + public string DisplayName { get; } = displayName; + + public IReadOnlyList PrerequisiteQuestIds { get; } = prerequisiteQuestIds; + + public IReadOnlyList Steps { get; } = steps; +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs new file mode 100644 index 0000000..df02f2b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs @@ -0,0 +1,25 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Quests; + +/// In-memory quest catalog loaded at startup (NEO-113). Game callers should use (NEO-114). +public sealed class QuestDefinitionCatalog( + string questsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + /// Absolute path to the directory that was enumerated for *_quests.json catalogs. + public string QuestsDirectory { get; } = questsDirectory; + + public IReadOnlyDictionary ById { get; } = + new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctQuestCount => ById.Count; + + /// Number of *_quests.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable quest . + public bool TryGetQuest(string id, out QuestDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs new file mode 100644 index 0000000..7d099f0 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -0,0 +1,386 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Quests; + +/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113). +public static class QuestDefinitionCatalogLoader +{ + private static readonly Lock SchemaLoadLock = new(); + private static JsonSchema? _questObjectiveDefSchema; + private static JsonSchema? _questStepDefSchema; + private static JsonSchema? _questDefSchema; + /// Loads catalogs from disk or throws with actionable messages. + public static QuestDefinitionCatalog Load( + string questsDirectory, + string questDefSchemaPath, + string questStepDefSchemaPath, + string questObjectiveDefSchemaPath, + IReadOnlySet knownItemIds, + IReadOnlySet knownRecipeIds, + IReadOnlySet knownEncounterIds, + ILogger logger) + { + questsDirectory = Path.GetFullPath(questsDirectory); + questDefSchemaPath = Path.GetFullPath(questDefSchemaPath); + questStepDefSchemaPath = Path.GetFullPath(questStepDefSchemaPath); + questObjectiveDefSchemaPath = Path.GetFullPath(questObjectiveDefSchemaPath); + + var errors = new List(); + + if (!File.Exists(questDefSchemaPath)) + errors.Add($"error: missing schema file {questDefSchemaPath}"); + + if (!File.Exists(questStepDefSchemaPath)) + errors.Add($"error: missing schema file {questStepDefSchemaPath}"); + + if (!File.Exists(questObjectiveDefSchemaPath)) + errors.Add($"error: missing schema file {questObjectiveDefSchemaPath}"); + + if (!Directory.Exists(questsDirectory)) + errors.Add($"error: missing directory {questsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(questsDirectory, "*_quests.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_quests.json files under {questsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + EnsureQuestSchemasLoaded(questObjectiveDefSchemaPath, questStepDefSchemaPath, questDefSchemaPath); + var defSchema = _questDefSchema!; + + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var questIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(StringComparer.Ordinal); + + foreach (var path in jsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var questsNode = rootObj["quests"]; + if (questsNode is not JsonArray questsArray) + { + errors.Add($"error: {path}: expected top-level 'quests' array"); + continue; + } + + for (var i = 0; i < questsArray.Count; i++) + { + var quest = questsArray[i]; + if (quest is not JsonObject rowObj) + { + errors.Add($"error: {path}: quests[{i}] must be an object"); + continue; + } + + var eval = defSchema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} quests[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var qid = (rowObj["id"] as JsonValue)?.GetValue(); + if (qid is not null && eval.IsValid) + { + if (questIdToSourceFile.TryGetValue(qid, out var prevPath)) + { + errors.Add($"error: duplicate quest id '{qid}' in {prevPath} and {path}"); + continue; + } + + var stepParseErrors = TryParseSteps(rowObj, path, i, out var steps, out _, out _); + errors.AddRange(stepParseErrors); + + questIdToSourceFile[qid] = path; + rows[qid] = ParseRow(rowObj, steps); + } + } + } + + ThrowIfAny(errors); + + var e7m1 = PrototypeE7M1QuestCatalogRules.TryGetE7M1GateError(questIdToSourceFile); + if (e7m1 is not null) + { + errors.Add(e7m1); + ThrowIfAny(errors); + } + + var prereq = PrototypeE7M1QuestCatalogRules.TryGetPrerequisiteGateError(rows); + if (prereq is not null) + { + errors.Add(prereq); + ThrowIfAny(errors); + } + + var crossRef = PrototypeE7M1QuestCatalogRules.TryGetCrossRefError( + rows, + knownItemIds, + knownRecipeIds, + knownEncounterIds); + if (crossRef is not null) + { + errors.Add(crossRef); + ThrowIfAny(errors); + } + + var chainTerminal = PrototypeE7M1QuestCatalogRules.TryGetChainTerminalGateError(rows); + if (chainTerminal is not null) + { + errors.Add(chainTerminal); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded quest catalog from {QuestsDirectory}: {QuestCount} quest(s) across {CatalogFileCount} JSON catalog file(s).", + questsDirectory, + rows.Count, + jsonFiles.Length); + } + + return new QuestDefinitionCatalog(questsDirectory, rows, jsonFiles.Length); + } + + private static List TryParseSteps( + JsonObject rowObj, + string path, + int questIndex, + out IReadOnlyList steps, + out HashSet stepIdsSeen, + out HashSet objectiveIdsSeen) + { + var errors = new List(); + stepIdsSeen = new HashSet(StringComparer.Ordinal); + objectiveIdsSeen = new HashSet(StringComparer.Ordinal); + var parsedSteps = new List(); + + var stepsNode = rowObj["steps"] as JsonArray; + if (stepsNode is null) + { + steps = parsedSteps; + return errors; + } + + for (var si = 0; si < stepsNode.Count; si++) + { + if (stepsNode[si] is not JsonObject stepObj) + continue; + + var sid = (stepObj["id"] as JsonValue)?.GetValue(); + if (sid is not null) + { + if (stepIdsSeen.Contains(sid)) + { + errors.Add($"error: {path} quests[{questIndex}] steps[{si}]: duplicate step id '{sid}'"); + } + else + { + stepIdsSeen.Add(sid); + } + } + + var objectivesNode = stepObj["objectives"] as JsonArray; + if (objectivesNode is null) + continue; + + for (var oi = 0; oi < objectivesNode.Count; oi++) + { + if (objectivesNode[oi] is not JsonObject objObj) + continue; + + var oid = (objObj["id"] as JsonValue)?.GetValue(); + if (oid is not null) + { + if (objectiveIdsSeen.Contains(oid)) + { + errors.Add( + $"error: {path} quests[{questIndex}] steps[{si}] objectives[{oi}]: " + + $"duplicate objective id '{oid}' within quest"); + } + else + { + objectiveIdsSeen.Add(oid); + } + } + } + } + + for (var si = 0; si < stepsNode.Count; si++) + { + if (stepsNode[si] is JsonObject stepObj) + parsedSteps.Add(ParseStep(stepObj)); + } + + steps = parsedSteps; + return errors; + } + + private static QuestDefRow ParseRow(JsonObject rowObj, IReadOnlyList steps) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray); + return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps); + } + + private static QuestStepDefRow ParseStep(JsonObject stepObj) + { + var id = (stepObj["id"] as JsonValue)!.GetValue(); + var displayName = (stepObj["displayName"] as JsonValue)!.GetValue(); + var objectivesNode = stepObj["objectives"] as JsonArray ?? []; + var objectives = new List(objectivesNode.Count); + foreach (var node in objectivesNode) + { + if (node is JsonObject objObj) + objectives.Add(ParseObjective(objObj)); + } + + return new QuestStepDefRow(id, displayName, objectives); + } + + private static QuestObjectiveDefRow ParseObjective(JsonObject objObj) + { + var id = (objObj["id"] as JsonValue)!.GetValue(); + var kind = (objObj["kind"] as JsonValue)!.GetValue(); + string? itemId = null; + int? quantity = null; + string? recipeId = null; + string? encounterId = null; + + if (objObj["itemId"] is JsonValue itemValue && itemValue.TryGetValue(out var item)) + itemId = item; + + if (objObj["quantity"] is JsonValue qtyValue && qtyValue.TryGetValue(out var qty)) + quantity = qty; + + if (objObj["recipeId"] is JsonValue recipeValue && recipeValue.TryGetValue(out var recipe)) + recipeId = recipe; + + if (objObj["encounterId"] is JsonValue encounterValue && encounterValue.TryGetValue(out var encounter)) + encounterId = encounter; + + return new QuestObjectiveDefRow(id, kind, itemId, quantity, recipeId, encounterId); + } + + private static IReadOnlyList ParseStringArray(JsonArray? array) + { + if (array is null) + return []; + + var values = new List(array.Count); + foreach (var node in array) + { + if (node is JsonValue value && value.TryGetValue(out var s)) + values.Add(s); + } + + return values; + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List sink) + { + if (r.HasDetails) + { + foreach (var d in r.Details!) + AppendSchemaMessages(d, filePath, index, sink); + } + + if (!r.HasErrors) + return; + + foreach (var kv in r.Errors!) + { + var loc = r.InstanceLocation?.ToString(); + if (string.IsNullOrEmpty(loc) || loc == "#") + loc = "(root)"; + + sink.Add($"error: {filePath} quests[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + /// Registers objective → step → def in once per process (NEO-113). + private static void EnsureQuestSchemasLoaded( + string questObjectiveDefSchemaPath, + string questStepDefSchemaPath, + string questDefSchemaPath) + { + lock (SchemaLoadLock) + { + if (_questDefSchema is not null) + return; + + var objectiveSchema = JsonSchema.FromText(File.ReadAllText(questObjectiveDefSchemaPath)); + SchemaRegistry.Global.Register(objectiveSchema); + _questObjectiveDefSchema = objectiveSchema; + + var stepSchema = JsonSchema.FromText(File.ReadAllText(questStepDefSchemaPath)); + SchemaRegistry.Global.Register(stepSchema); + _questStepDefSchema = stepSchema; + + var defSchema = JsonSchema.FromText(File.ReadAllText(questDefSchemaPath)); + SchemaRegistry.Global.Register(defSchema); + _questDefSchema = defSchema; + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Quest catalog validation failed:"); + foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) + sb.AppendLine(e); + + throw new InvalidOperationException(sb.ToString().TrimEnd()); + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs new file mode 100644 index 0000000..e595e1e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs @@ -0,0 +1,23 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Load-time quest objective row (NEO-113). +public sealed class QuestObjectiveDefRow( + string id, + string kind, + string? itemId, + int? quantity, + string? recipeId, + string? encounterId) +{ + public string Id { get; } = id; + + public string Kind { get; } = kind; + + public string? ItemId { get; } = itemId; + + public int? Quantity { get; } = quantity; + + public string? RecipeId { get; } = recipeId; + + public string? EncounterId { get; } = encounterId; +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs new file mode 100644 index 0000000..8285a5d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Load-time quest step row (NEO-113). +public sealed class QuestStepDefRow( + string id, + string displayName, + IReadOnlyList objectives) +{ + public string Id { get; } = id; + + public string DisplayName { get; } = displayName; + + public IReadOnlyList Objectives { get; } = objectives; +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 5d89b7f..e3cccbc 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -130,4 +130,28 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of encounters directory}/schemas/encounter-def.schema.json. /// public string? EncounterDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/quests directory. + /// + public string? QuestsDirectory { get; set; } + + /// + /// Optional override for quest-def.schema.json. + /// When unset, resolved as {parent of quests directory}/schemas/quest-def.schema.json. + /// + public string? QuestDefSchemaPath { get; set; } + + /// + /// Optional override for quest-step-def.schema.json. + /// When unset, resolved as {parent of quests directory}/schemas/quest-step-def.schema.json. + /// + public string? QuestStepDefSchemaPath { get; set; } + + /// + /// Optional override for quest-objective-def.schema.json. + /// When unset, resolved as {parent of quests directory}/schemas/quest-objective-def.schema.json. + /// + public string? QuestObjectiveDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 7bf916a..f664013 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Targeting; @@ -27,6 +28,7 @@ builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); +builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddThreatStateStore(); builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddPlayerCombatHealthStore(); @@ -41,6 +43,7 @@ _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index 572c74a..7ed0461 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -22,7 +22,11 @@ "RewardTableDefSchemaPath": "", "RewardGrantRowSchemaPath": "", "EncountersDirectory": "", - "EncounterDefSchemaPath": "" + "EncounterDefSchemaPath": "", + "QuestsDirectory": "", + "QuestDefSchemaPath": "", + "QuestStepDefSchemaPath": "", + "QuestObjectiveDefSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1", diff --git a/server/README.md b/server/README.md index 019be62..7ab6957 100644 --- a/server/README.md +++ b/server/README.md @@ -133,6 +133,21 @@ On startup the host loads every **`*_encounters.json`** under the encounters dir On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Game code should use **`IEncounterDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`EncounterDefinitionCatalog`** 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 ability/hotbar routes. +## Quest catalog (`content/quests`, NEO-113) + +On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, and encounter catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`** and **`quest-objective-def.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, and enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. + +| Config | Meaning | +|--------|---------| +| **`Content:QuestsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/quests`**. | +| **`Content:QuestDefSchemaPath`** | Optional override for **`quest-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-def.schema.json`**. | +| **`Content:QuestStepDefSchemaPath`** | Optional override for **`quest-step-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-step-def.schema.json`**. | +| **`Content:QuestObjectiveDefSchemaPath`** | Optional override for **`quest-objective-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-objective-def.schema.json`**. | + +**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**), and the three quest schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs. + +On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. **`IQuestDefinitionRegistry`** (NEO-114) will be the lookup surface for game code; until then the **`QuestDefinitionCatalog`** singleton is registered for fail-fast startup only—do not inject it in new game code. + ## 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/`.