NEO-113: add fail-fast quest catalog load and loader tests.

pull/152/head
VinPropane 2026-05-31 22:33:43 -04:00
parent cb4761b0b4
commit b841b64581
17 changed files with 1480 additions and 1 deletions

View File

@ -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");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: quest-catalog
}

View File

@ -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);
}

View File

@ -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<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds;
private static readonly FrozenSet<string> 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<string>() == 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<QuestDefinitionCatalog>();
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<Program>().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);
}
}
}

View File

@ -12,6 +12,7 @@ using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
@ -58,6 +59,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var encountersDir = EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory) var encountersDir = EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir); builder.UseSetting("Content:ItemsDirectory", itemsDir);
@ -67,6 +71,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir); builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
builder.UseSetting("Content:EncountersDirectory", encountersDir); builder.UseSetting("Content:EncountersDirectory", encountersDir);
builder.UseSetting("Content:QuestsDirectory", questsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>

View File

@ -0,0 +1,182 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>,
/// <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>, <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>,
/// and <c>_prototype_e7m1_*</c> gate functions.
/// </summary>
public static class PrototypeE7M1QuestCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedQuestIds = FrozenSet.ToFrozenSet(
[
"prototype_quest_gather_intro",
"prototype_quest_refine_intro",
"prototype_quest_combat_intro",
"prototype_quest_operator_chain",
],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary>
public const string ChainQuestId = "prototype_quest_operator_chain";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>.</summary>
public const string ChainTerminalItemId = "contract_handoff_token";
/// <summary>Returns a human-readable error if the E7M1 quest contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetE7M1GateError(IReadOnlyDictionary<string, string> 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;
}
/// <summary>Returns a human-readable error when prerequisite ids are unknown or cyclic, otherwise <see langword="null"/>.</summary>
public static string? TryGetPrerequisiteGateError(IReadOnlyDictionary<string, QuestDefRow> 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<string>(StringComparer.Ordinal);
var visited = new HashSet<string>(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;
}
/// <summary>Returns a human-readable error when objective catalog cross-refs fail, otherwise <see langword="null"/>.</summary>
public static string? TryGetCrossRefError(
IReadOnlyDictionary<string, QuestDefRow> rowsById,
IReadOnlySet<string> knownItemIds,
IReadOnlySet<string> knownRecipeIds,
IReadOnlySet<string> 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;
}
/// <summary>Returns a human-readable error when the operator chain terminal step fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetChainTerminalGateError(IReadOnlyDictionary<string, QuestDefRow> 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;
}
}

View File

@ -0,0 +1,96 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Resolves quest catalog paths for local dev, tests, and container layouts (NEO-113).</summary>
public static class QuestCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/quests</c> directory.</summary>
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;
}
/// <summary>
/// Resolves the quests catalog directory.
/// Empty <paramref name="configuredQuestsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
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));
}
/// <summary>Resolves JSON Schema path for a single quest row (Draft 2020-12).</summary>
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"));
}
/// <summary>Resolves JSON Schema path for a quest step row.</summary>
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"));
}
/// <summary>Resolves JSON Schema path for a quest objective row.</summary>
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"));
}
}

View File

@ -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;
/// <summary>DI registration for the fail-fast quest catalog (NEO-113).</summary>
public static class QuestCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="QuestDefinitionCatalog"/> as a singleton.</summary>
public static IServiceCollection AddQuestDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<QuestDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>()
.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;
}
}

View File

@ -0,0 +1,17 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Load-time quest row (NEO-113).</summary>
public sealed class QuestDefRow(
string id,
string displayName,
IReadOnlyList<string> prerequisiteQuestIds,
IReadOnlyList<QuestStepDefRow> steps)
{
public string Id { get; } = id;
public string DisplayName { get; } = displayName;
public IReadOnlyList<string> PrerequisiteQuestIds { get; } = prerequisiteQuestIds;
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
}

View File

@ -0,0 +1,25 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>In-memory quest catalog loaded at startup (NEO-113). Game callers should use <see cref="IQuestDefinitionRegistry"/> (NEO-114).</summary>
public sealed class QuestDefinitionCatalog(
string questsDirectory,
IReadOnlyDictionary<string, QuestDefRow> byId,
int catalogJsonFileCount)
{
/// <summary>Absolute path to the directory that was enumerated for <c>*_quests.json</c> catalogs.</summary>
public string QuestsDirectory { get; } = questsDirectory;
public IReadOnlyDictionary<string, QuestDefRow> ById { get; } =
new ReadOnlyDictionary<string, QuestDefRow>(new Dictionary<string, QuestDefRow>(byId, StringComparer.Ordinal));
public int DistinctQuestCount => ById.Count;
/// <summary>Number of <c>*_quests.json</c> files under <see cref="QuestsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
/// <summary>Resolves a catalog row by stable quest <paramref name="id"/>.</summary>
public bool TryGetQuest(string id, out QuestDefRow? row) =>
ById.TryGetValue(id, out row);
}

View File

@ -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;
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113).</summary>
public static class QuestDefinitionCatalogLoader
{
private static readonly Lock SchemaLoadLock = new();
private static JsonSchema? _questObjectiveDefSchema;
private static JsonSchema? _questStepDefSchema;
private static JsonSchema? _questDefSchema;
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static QuestDefinitionCatalog Load(
string questsDirectory,
string questDefSchemaPath,
string questStepDefSchemaPath,
string questObjectiveDefSchemaPath,
IReadOnlySet<string> knownItemIds,
IReadOnlySet<string> knownRecipeIds,
IReadOnlySet<string> knownEncounterIds,
ILogger logger)
{
questsDirectory = Path.GetFullPath(questsDirectory);
questDefSchemaPath = Path.GetFullPath(questDefSchemaPath);
questStepDefSchemaPath = Path.GetFullPath(questStepDefSchemaPath);
questObjectiveDefSchemaPath = Path.GetFullPath(questObjectiveDefSchemaPath);
var errors = new List<string>();
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<string, string>(StringComparer.Ordinal);
var rows = new Dictionary<string, QuestDefRow>(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<int>(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<string>();
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<string> TryParseSteps(
JsonObject rowObj,
string path,
int questIndex,
out IReadOnlyList<QuestStepDefRow> steps,
out HashSet<string> stepIdsSeen,
out HashSet<string> objectiveIdsSeen)
{
var errors = new List<string>();
stepIdsSeen = new HashSet<string>(StringComparer.Ordinal);
objectiveIdsSeen = new HashSet<string>(StringComparer.Ordinal);
var parsedSteps = new List<QuestStepDefRow>();
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<string>();
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<string>();
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<QuestStepDefRow> steps)
{
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
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<string>();
var displayName = (stepObj["displayName"] as JsonValue)!.GetValue<string>();
var objectivesNode = stepObj["objectives"] as JsonArray ?? [];
var objectives = new List<QuestObjectiveDefRow>(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<string>();
var kind = (objObj["kind"] as JsonValue)!.GetValue<string>();
string? itemId = null;
int? quantity = null;
string? recipeId = null;
string? encounterId = null;
if (objObj["itemId"] is JsonValue itemValue && itemValue.TryGetValue<string>(out var item))
itemId = item;
if (objObj["quantity"] is JsonValue qtyValue && qtyValue.TryGetValue<int>(out var qty))
quantity = qty;
if (objObj["recipeId"] is JsonValue recipeValue && recipeValue.TryGetValue<string>(out var recipe))
recipeId = recipe;
if (objObj["encounterId"] is JsonValue encounterValue && encounterValue.TryGetValue<string>(out var encounter))
encounterId = encounter;
return new QuestObjectiveDefRow(id, kind, itemId, quantity, recipeId, encounterId);
}
private static IReadOnlyList<string> ParseStringArray(JsonArray? array)
{
if (array is null)
return [];
var values = new List<string>(array.Count);
foreach (var node in array)
{
if (node is JsonValue value && value.TryGetValue<string>(out var s))
values.Add(s);
}
return values;
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> 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}");
}
}
/// <summary>Registers objective → step → def in <see cref="SchemaRegistry.Global"/> once per process (NEO-113).</summary>
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<string> 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());
}
}

View File

@ -0,0 +1,23 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Load-time quest objective row (NEO-113).</summary>
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;
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Load-time quest step row (NEO-113).</summary>
public sealed class QuestStepDefRow(
string id,
string displayName,
IReadOnlyList<QuestObjectiveDefRow> objectives)
{
public string Id { get; } = id;
public string DisplayName { get; } = displayName;
public IReadOnlyList<QuestObjectiveDefRow> Objectives { get; } = objectives;
}

View File

@ -130,4 +130,28 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of encounters directory}/schemas/encounter-def.schema.json</c>. /// When unset, resolved as <c>{parent of encounters directory}/schemas/encounter-def.schema.json</c>.
/// </summary> /// </summary>
public string? EncounterDefSchemaPath { get; set; } public string? EncounterDefSchemaPath { get; set; }
/// <summary>
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/quests</c> directory.
/// </summary>
public string? QuestsDirectory { get; set; }
/// <summary>
/// Optional override for <c>quest-def.schema.json</c>.
/// When unset, resolved as <c>{parent of quests directory}/schemas/quest-def.schema.json</c>.
/// </summary>
public string? QuestDefSchemaPath { get; set; }
/// <summary>
/// Optional override for <c>quest-step-def.schema.json</c>.
/// When unset, resolved as <c>{parent of quests directory}/schemas/quest-step-def.schema.json</c>.
/// </summary>
public string? QuestStepDefSchemaPath { get; set; }
/// <summary>
/// Optional override for <c>quest-objective-def.schema.json</c>.
/// When unset, resolved as <c>{parent of quests directory}/schemas/quest-objective-def.schema.json</c>.
/// </summary>
public string? QuestObjectiveDefSchemaPath { get; set; }
} }

View File

@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
@ -27,6 +28,7 @@ builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
builder.Services.AddThreatStateStore(); builder.Services.AddThreatStateStore();
builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddNpcRuntimeStateStore();
builder.Services.AddPlayerCombatHealthStore(); builder.Services.AddPlayerCombatHealthStore();
@ -41,6 +43,7 @@ _ = app.Services.GetRequiredService<AbilityDefinitionCatalog>();
_ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>(); _ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
_ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>(); _ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>();
_ = app.Services.GetRequiredService<EncounterDefinitionCatalog>(); _ = app.Services.GetRequiredService<EncounterDefinitionCatalog>();
_ = app.Services.GetRequiredService<QuestDefinitionCatalog>();
_ = app.Services.GetRequiredService<MasteryCatalog>(); _ = app.Services.GetRequiredService<MasteryCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>(); _ = app.Services.GetRequiredService<ISkillLevelCurve>();

View File

@ -22,7 +22,11 @@
"RewardTableDefSchemaPath": "", "RewardTableDefSchemaPath": "",
"RewardGrantRowSchemaPath": "", "RewardGrantRowSchemaPath": "",
"EncountersDirectory": "", "EncountersDirectory": "",
"EncounterDefSchemaPath": "" "EncounterDefSchemaPath": "",
"QuestsDirectory": "",
"QuestDefSchemaPath": "",
"QuestStepDefSchemaPath": "",
"QuestObjectiveDefSchemaPath": ""
}, },
"Game": { "Game": {
"DevPlayerId": "dev-local-1", "DevPlayerId": "dev-local-1",

View File

@ -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. 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) ## 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/`. **`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/`.