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.Game.Skills; using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests.Game.Skills; 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 static readonly IReadOnlyDictionary RepoSkillDefs = LoadRepoSkillDefs(); private static IReadOnlyDictionary LoadRepoSkillDefs() { var catalog = SkillDefinitionCatalogLoader.Load( SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(), SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(), NullLogger.Instance); return catalog.ById; } private const string ValidPrototypeCatalogJson = """ { "schemaVersion": 1, "quests": [ { "id": "prototype_quest_gather_intro", "displayName": "Intro: Salvage Run", "prerequisiteQuestIds": [], "completionRewardBundle": { "skillXpGrants": [{ "skillId": "salvage", "amount": 25 }] }, "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"], "completionRewardBundle": { "skillXpGrants": [{ "skillId": "refine", "amount": 25 }] }, "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": [], "completionRewardBundle": { "skillXpGrants": [{ "skillId": "salvage", "amount": 25 }] }, "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" ], "completionRewardBundle": { "itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }], "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }], "reputationGrants": [ { "factionId": "prototype_faction_grid_operators", "amount": 15 } ] }, "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 } ] } ] }, { "id": "prototype_quest_grid_contract", "displayName": "Grid Contract", "prerequisiteQuestIds": ["prototype_quest_operator_chain"], "factionGateRules": [ { "factionId": "prototype_faction_grid_operators", "minStanding": 15 } ], "completionRewardBundle": { "reputationGrants": [ { "factionId": "prototype_faction_rust_collective", "amount": 10 } ] }, "steps": [ { "id": "grid_contract_step_kit", "displayName": "Deliver survey drone kit", "objectives": [ { "id": "grid_contract_obj_kit", "kind": "inventory_has_item", "itemId": "survey_drone_kit", "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); File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), 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, IReadOnlyDictionary? skillDefsById = null) => QuestDefinitionCatalogLoader.Load( questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath, QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(questsDir), QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(questsDir), QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(questsDir), KnownItemIds, KnownRecipeIds, KnownEncounterIds, skillDefsById ?? RepoSkillDefs, 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(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, 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(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, 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); Assert.NotNull(gather!.CompletionRewardBundle); Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants); Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId); Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount); } [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_WhenRowViolatesSchema() { // Arrange var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); const string bad = """ { "schemaVersion": 1, "quests": [ { "id": "prototype_quest_gather_intro", "displayName": "", "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 } ] } ] } ] } """; File.WriteAllText(Path.Combine(questsDir, "bad_quests.json"), bad, 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("Quest catalog validation failed", 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_WhenChainTerminalQuantityIsWrong() { // 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["quantity"] = 2; WriteCatalog(questsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("terminal quantity must be 1", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenMissingCompletionRewardBundleOnFrozenQuest() { // 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.Remove("completionRewardBundle"); WriteCatalog(questsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("must include completionRewardBundle object", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenCompletionRewardBundleAmountDoesNotMatchFreezeTable() { // 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 bundle = gather["completionRewardBundle"] as JsonObject ?? throw new InvalidOperationException("expected bundle"); var skillXpGrants = bundle["skillXpGrants"] as JsonArray ?? throw new InvalidOperationException("expected skillXpGrants"); var grant = skillXpGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant"); grant["amount"] = 99; WriteCatalog(questsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenCompletionRewardBundleItemIdDoesNotMatchFreezeTable() { // 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 bundle = chain["completionRewardBundle"] as JsonObject ?? throw new InvalidOperationException("expected bundle"); var itemGrants = bundle["itemGrants"] as JsonArray ?? throw new InvalidOperationException("expected itemGrants"); var grant = itemGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant"); grant["itemId"] = "unknown_item_id"; WriteCatalog(questsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); // Assert — freeze gate runs before cross-ref (same order as validate_content.py) var ioe = Assert.IsType(ex); Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenCompletionRewardBundleSkillIdDoesNotMatchFreezeTable() { // 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 bundle = gather["completionRewardBundle"] as JsonObject ?? throw new InvalidOperationException("expected bundle"); var skillXpGrants = bundle["skillXpGrants"] as JsonArray ?? throw new InvalidOperationException("expected skillXpGrants"); var grant = skillXpGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant"); grant["skillId"] = "unknown_skill_id"; WriteCatalog(questsDir, root.ToJsonString()); // Act var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); // Assert — freeze gate runs before cross-ref (same order as validate_content.py) var ioe = Assert.IsType(ex); Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSkillMissingMissionRewardAllowlist() { // Arrange var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject ?? throw new InvalidOperationException("expected object"); WriteCatalog(questsDir, root.ToJsonString()); var salvageNoMissionReward = new SkillDefRow("salvage", "gather", "Salvage", ["activity"]); var skillDefs = new Dictionary(RepoSkillDefs, StringComparer.Ordinal) { ["salvage"] = salvageNoMissionReward, }; // Act var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath, skillDefs)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", 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(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, 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); } } }