From 48bca98467a423df55d75166d9de1675d259acff Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 20:08:46 -0400 Subject: [PATCH] NEO-134: fail-fast faction catalog load with E7M3 quest gates Add Game/Factions loader, registry, and CI-parity prototype rules; extend quest catalog to parse faction gates/rep grants and cross-check at startup. --- .../Health after faction catalog load.bru | 25 ++ .../faction-catalog/folder.bru | 3 + docs/plans/NEO-134-implementation-plan.md | 13 +- .../Game/Factions/FactionCatalogTestPaths.cs | 17 ++ .../FactionDefinitionCatalogLoaderTests.cs | 191 ++++++++++++++ .../FactionDefinitionRegistryTests.cs | 126 ++++++++++ .../Game/Quests/QuestCatalogTestPaths.cs | 3 + .../QuestDefinitionCatalogLoaderTests.cs | 89 ++++++- .../InMemoryWebApplicationFactory.cs | 5 + .../Factions/FactionCatalogPathResolution.cs | 60 +++++ ...ctionCatalogServiceCollectionExtensions.cs | 40 +++ .../Game/Factions/FactionDefRow.cs | 9 + .../Game/Factions/FactionDefinitionCatalog.cs | 27 ++ .../FactionDefinitionCatalogLoader.cs | 198 +++++++++++++++ .../Factions/FactionDefinitionRegistry.cs | 39 +++ .../Factions/IFactionDefinitionRegistry.cs | 15 ++ .../PrototypeE7M3FactionCatalogRules.cs | 95 +++++++ .../Game/Quests/FactionGateRuleRow.cs | 4 + .../Quests/PrototypeE7M3QuestFactionRules.cs | 235 ++++++++++++++++++ ...QuestCatalogServiceCollectionExtensions.cs | 4 + .../Game/Quests/QuestDefRow.cs | 7 +- .../Quests/QuestDefinitionCatalogLoader.cs | 68 ++++- .../Game/Quests/QuestRewardBundleRow.cs | 13 +- .../Game/Quests/ReputationGrantRow.cs | 4 + .../Game/Skills/ContentPathsOptions.cs | 12 + server/NeonSprawl.Server/Program.cs | 3 + server/README.md | 17 +- 27 files changed, 1309 insertions(+), 13 deletions(-) create mode 100644 bruno/neon-sprawl-server/faction-catalog/Health after faction catalog load.bru create mode 100644 bruno/neon-sprawl-server/faction-catalog/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs diff --git a/bruno/neon-sprawl-server/faction-catalog/Health after faction catalog load.bru b/bruno/neon-sprawl-server/faction-catalog/Health after faction catalog load.bru new file mode 100644 index 0000000..957fbf4 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-catalog/Health after faction catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (faction catalog boot NEO-134) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-134 loads content/factions/*_factions.json at startup (fail-fast). No faction 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/faction-catalog/folder.bru b/bruno/neon-sprawl-server/faction-catalog/folder.bru new file mode 100644 index 0000000..c7f1762 --- /dev/null +++ b/bruno/neon-sprawl-server/faction-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: faction-catalog +} diff --git a/docs/plans/NEO-134-implementation-plan.md b/docs/plans/NEO-134-implementation-plan.md index bb45dd1..370f8d0 100644 --- a/docs/plans/NEO-134-implementation-plan.md +++ b/docs/plans/NEO-134-implementation-plan.md @@ -43,9 +43,16 @@ Fail-fast startup load of `content/factions/*_factions.json` with **CI-parity** ## Acceptance criteria checklist -- [ ] Host exits on invalid faction catalog or broken quest faction refs at startup. -- [ ] Registry resolves faction metadata by id. -- [ ] `dotnet test` covers loader gates. +- [x] Host exits on invalid faction catalog or broken quest faction refs at startup. +- [x] Registry resolves faction metadata by id. +- [x] `dotnet test` covers loader gates. + +## Implementation reconciliation (shipped) + +- **Faction catalog:** `Game/Factions/` loader, catalog, `IFactionDefinitionRegistry`, `PrototypeE7M3FactionCatalogRules`, DI + `Program.cs` load order (after skills, before quests). +- **Quest extensions:** `FactionGateRuleRow`, `ReputationGrantRow`, `PrototypeE7M3QuestFactionRules` (cross-ref, E7M3 bundle freeze, grid-contract shape); quest loader parses faction fields and runs E7M3 gates. +- **Tests:** `FactionDefinitionCatalogLoaderTests`, `FactionDefinitionRegistryTests`, four new quest-loader negative cases; host boot resolves faction + quest catalogs (`746` tests green). +- **Docs:** `server/README.md` faction catalog section; quest catalog paragraph updated for E7M3 gates. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs new file mode 100644 index 0000000..e5bdc71 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs @@ -0,0 +1,17 @@ +using NeonSprawl.Server.Game.Factions; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +internal static class FactionCatalogTestPaths +{ + internal static string DiscoverRepoFactionsDirectory() => + FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoFactionDefSchemaPath() => + FactionCatalogPathResolution.ResolveFactionDefSchemaPath( + DiscoverRepoFactionsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..70cce18 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs @@ -0,0 +1,191 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +public class FactionDefinitionCatalogLoaderTests +{ + private const string ValidPrototypeCatalogJson = + """ + { + "schemaVersion": 1, + "factions": [ + { + "id": "prototype_faction_grid_operators", + "displayName": "Grid Operators", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0 + }, + { + "id": "prototype_faction_rust_collective", + "displayName": "Rust Collective", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0 + } + ] + } + """; + + private static (string Root, string FactionsDir, string SchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-factioncat-"); + var factionsDir = Path.Combine(root.FullName, "content", "factions"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(factionsDir); + Directory.CreateDirectory(schemaDir); + var schemaPath = Path.Combine(schemaDir, "faction-def.schema.json"); + File.Copy(FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath(), schemaPath, overwrite: true); + return (root.FullName, factionsDir, schemaPath); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile() + { + // Arrange + var factionsDir = FactionCatalogTestPaths.DiscoverRepoFactionsDirectory(); + var schemaPath = FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath(); + // Act + var catalog = FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance); + // Assert + Assert.Equal(PrototypeE7M3FactionCatalogRules.ExpectedFactionIds.Count, catalog.DistinctFactionCount); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), ValidPrototypeCatalogJson, Encoding.UTF8); + // Act + var catalog = FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance); + // Assert + Assert.Equal(2, catalog.DistinctFactionCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.ById.TryGetValue("prototype_faction_grid_operators", out var grid)); + Assert.NotNull(grid); + Assert.Equal("Grid Operators", grid!.DisplayName); + Assert.Equal(0, grid.NeutralStanding); + } + + [Fact] + public void Load_ShouldThrow_WhenFactionsIsNotArray() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(factionsDir, "bad_factions.json"), """{"schemaVersion": 1, "factions": "nope"}""", Encoding.UTF8); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_factions.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'factions' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + var singleFaction = """ + { + "schemaVersion": 1, + "factions": [ + { + "id": "prototype_faction_grid_operators", + "displayName": "Grid Operators", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0 + } + ] + } + """; + File.WriteAllText(Path.Combine(factionsDir, "a_factions.json"), singleFaction, Encoding.UTF8); + File.WriteAllText(Path.Combine(factionsDir, "b_factions.json"), singleFaction, Encoding.UTF8); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate faction id", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenRosterGateFails() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + var oneFaction = """ + { + "schemaVersion": 1, + "factions": [ + { + "id": "prototype_faction_grid_operators", + "displayName": "Grid Operators", + "minStanding": -100, + "maxStanding": 100, + "neutralStanding": 0 + } + ] + } + """; + File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), oneFaction, Encoding.UTF8); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E7M3 expects exactly faction ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenFreezeTableDiverges() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + var badDisplay = ValidPrototypeCatalogJson.Replace("Grid Operators", "Wrong Name", StringComparison.Ordinal); + File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), badDisplay, Encoding.UTF8); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("must match E7M3 freeze table", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void TryGetStandingBandGateError_ShouldReturnError_WhenOrderingFails() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + 10, + 100, + 0), + }; + // Act + var error = PrototypeE7M3FactionCatalogRules.TryGetStandingBandGateError(rows); + // Assert + Assert.NotNull(error); + Assert.Contains("requires minStanding <= neutralStanding <= maxStanding", error, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaFileMissing() + { + // Arrange + var (_, factionsDir, schemaPath) = CreateTempContentLayout(); + File.Delete(schemaPath); + // Act + var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs new file mode 100644 index 0000000..91b1a55 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs @@ -0,0 +1,126 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Factions; + +public class FactionDefinitionRegistryTests +{ + private static FactionDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary byId) + { + var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); + return new FactionDefinitionRegistry(catalog); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrue_WhenIdExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("prototype_faction_grid_operators", out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal("Grid Operators", def!.DisplayName); + Assert.Equal(-100, def.MinStanding); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenFactionIdIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(null, out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("not_a_real_faction", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleFactions() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_rust_collective"] = new( + "prototype_faction_rust_collective", + "Rust Collective", + -100, + 100, + 0), + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(2, list.Count); + Assert.Equal("prototype_faction_grid_operators", list[0].Id); + Assert.Equal("prototype_faction_rust_collective", list[1].Id); + } + + [Fact] + public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + // Act + var registry = factory.Services.GetRequiredService(); + var found = registry.TryGetDefinition("prototype_faction_grid_operators", out var grid); + // Assert + Assert.True(found); + Assert.NotNull(grid); + Assert.Equal("Grid Operators", grid!.DisplayName); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs index 26f0911..96867ae 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs @@ -43,4 +43,7 @@ internal static class QuestCatalogTestPaths internal static string DiscoverRepoReputationGrantRowSchemaPath() => Path.GetFullPath( Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "reputation-grant-row.schema.json")); + + internal static string DiscoverRepoFactionsDirectory() => + Path.GetFullPath(Path.Combine(DiscoverRepoQuestsDirectory(), "..", "factions")); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs index 8dc20c6..79ee3cc 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Skills; @@ -18,6 +19,8 @@ namespace NeonSprawl.Server.Tests.Game.Quests; public class QuestDefinitionCatalogLoaderTests { + private static readonly FrozenSet KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds; + private static readonly FrozenSet KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds; private static readonly FrozenSet KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds; @@ -247,7 +250,8 @@ public class QuestDefinitionCatalogLoaderTests string defSchemaPath, string stepSchemaPath, string objectiveSchemaPath, - IReadOnlyDictionary? skillDefsById = null) => + IReadOnlyDictionary? skillDefsById = null, + IReadOnlySet? knownFactionIds = null) => QuestDefinitionCatalogLoader.Load( questsDir, defSchemaPath, @@ -259,6 +263,7 @@ public class QuestDefinitionCatalogLoaderTests KnownItemIds, KnownRecipeIds, KnownEncounterIds, + knownFactionIds ?? KnownFactionIds, skillDefsById ?? RepoSkillDefs, NullLogger.Instance); @@ -752,6 +757,88 @@ public class QuestDefinitionCatalogLoaderTests Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal); } + [Fact] + public void Load_ShouldThrow_WhenFactionGateReferencesUnknownFaction() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var grid = GetQuestRow(root, "prototype_quest_grid_contract"); + var gateRules = grid["factionGateRules"] as JsonArray + ?? throw new InvalidOperationException("expected factionGateRules"); + (gateRules[0] as JsonObject)!["factionId"] = "unknown_faction_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("factionGateRules[0]", ioe.Message, StringComparison.Ordinal); + Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenReputationGrantReferencesUnknownFaction() + { + // 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 completionRewardBundle"); + var grants = bundle["reputationGrants"] as JsonArray + ?? throw new InvalidOperationException("expected reputationGrants"); + (grants[0] as JsonObject)!["factionId"] = "unknown_faction_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("reputationGrants[0]", ioe.Message, StringComparison.Ordinal); + Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenE7M3CompletionBundleFreezeDiverges() + { + // 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 completionRewardBundle"); + var grants = bundle["reputationGrants"] as JsonArray + ?? throw new InvalidOperationException("expected reputationGrants"); + (grants[0] as JsonObject)!["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 E7M3 freeze table", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenGridContractGateMinStandingDiverges() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var grid = GetQuestRow(root, "prototype_quest_grid_contract"); + var gateRules = grid["factionGateRules"] as JsonArray + ?? throw new InvalidOperationException("expected factionGateRules"); + (gateRules[0] as JsonObject)!["minStanding"] = 10; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("factionGateRules must be", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenMissingSchemaFile() { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 5e74ce8..2d2043e 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -8,6 +8,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; @@ -62,6 +63,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactoryResolves faction catalog paths for local dev, tests, and container layouts (NEO-134). +public static class FactionCatalogPathResolution +{ + /// Walks and parents for an existing content/factions directory. + public static string? TryDiscoverFactionsDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "factions"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the factions catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveFactionsDirectory(string? configuredFactionsDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredFactionsDirectory)) + { + var discovered = TryDiscoverFactionsDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:FactionsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/factions'). " + + "Set Content:FactionsDirectory in configuration or environment (e.g. Content__FactionsDirectory)."); + } + + var trimmed = configuredFactionsDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single faction row (Draft 2020-12). + public static string ResolveFactionDefSchemaPath( + string factionsDirectory, + 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(factionsDirectory, "..", "schemas", "faction-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..d9c5255 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Factions; + +/// DI registration for the fail-fast faction catalog (NEO-134). +public static class FactionCatalogServiceCollectionExtensions +{ + /// Binds and registers and as singletons. + public static IServiceCollection AddFactionDefinitionCatalog(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 logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Factions.FactionCatalog"); + + var factionsDir = FactionCatalogPathResolution.ResolveFactionsDirectory( + opts.FactionsDirectory, + hostEnv.ContentRootPath); + var schemaPath = FactionCatalogPathResolution.ResolveFactionDefSchemaPath( + factionsDir, + opts.FactionDefSchemaPath, + hostEnv.ContentRootPath); + + return FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, logger); + }); + + services.AddSingleton(sp => + new FactionDefinitionRegistry(sp.GetRequiredService())); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs b/server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs new file mode 100644 index 0000000..4fdbb1b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Factions; + +/// Load-time faction row (NEO-134). +public sealed record FactionDefRow( + string Id, + string DisplayName, + int MinStanding, + int MaxStanding, + int NeutralStanding); diff --git a/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs new file mode 100644 index 0000000..5b9088a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs @@ -0,0 +1,27 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Factions; + +/// In-memory faction catalog loaded at startup (NEO-134). Game code should prefer for lookups. +public sealed class FactionDefinitionCatalog +{ + public FactionDefinitionCatalog( + string factionsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) + { + FactionsDirectory = factionsDirectory; + ById = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + CatalogJsonFileCount = catalogJsonFileCount; + } + + /// Absolute path to the directory that was enumerated for *_factions.json catalogs. + public string FactionsDirectory { get; } + + public IReadOnlyDictionary ById { get; } + + public int DistinctFactionCount => ById.Count; + + /// Number of *_factions.json files under . + public int CatalogJsonFileCount { get; } +} diff --git a/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs new file mode 100644 index 0000000..b795614 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs @@ -0,0 +1,198 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Factions; + +/// Loads and validates content/factions/*_factions.json using the same rules as scripts/validate_content.py (NEO-134). +public static class FactionDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static FactionDefinitionCatalog Load(string factionsDirectory, string schemaPath, ILogger logger) + { + factionsDirectory = Path.GetFullPath(factionsDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + if (!File.Exists(schemaPath)) + errors.Add($"error: missing schema file {schemaPath}"); + + if (!Directory.Exists(factionsDirectory)) + errors.Add($"error: missing directory {factionsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(factionsDirectory, "*_factions.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_factions.json files under {factionsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var schema = JsonSchema.FromText(File.ReadAllText(schemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var factionIdToSourceFile = 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 factionsNode = rootObj["factions"]; + if (factionsNode is not JsonArray factionsArray) + { + errors.Add($"error: {path}: expected top-level 'factions' array"); + continue; + } + + for (var i = 0; i < factionsArray.Count; i++) + { + var item = factionsArray[i]; + if (item is not JsonObject rowObj) + { + errors.Add($"error: {path}: factions[{i}] must be an object"); + continue; + } + + var eval = schema.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} factions[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var fid = (rowObj["id"] as JsonValue)?.GetValue(); + if (fid is not null && rowSchemaErrors == 0) + { + if (factionIdToSourceFile.TryGetValue(fid, out var prevPath)) + { + errors.Add($"error: duplicate faction id '{fid}' in {prevPath} and {path}"); + continue; + } + + factionIdToSourceFile[fid] = path; + rows[fid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var roster = PrototypeE7M3FactionCatalogRules.TryGetRosterGateError(factionIdToSourceFile); + if (roster is not null) + { + errors.Add(roster); + ThrowIfAny(errors); + } + + var freeze = PrototypeE7M3FactionCatalogRules.TryGetFreezeGateError(rows); + if (freeze is not null) + { + errors.Add(freeze); + ThrowIfAny(errors); + } + + var standingBand = PrototypeE7M3FactionCatalogRules.TryGetStandingBandGateError(rows); + if (standingBand is not null) + { + errors.Add(standingBand); + ThrowIfAny(errors); + } + + logger.LogInformation( + "Loaded faction catalog from {FactionsDirectory}: {FactionCount} faction(s) across {CatalogFileCount} JSON catalog file(s).", + factionsDirectory, + rows.Count, + jsonFiles.Length); + + return new FactionDefinitionCatalog(factionsDirectory, rows, jsonFiles.Length); + } + + private static FactionDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var minStanding = (rowObj["minStanding"] as JsonValue)!.GetValue(); + var maxStanding = (rowObj["maxStanding"] as JsonValue)!.GetValue(); + var neutralStanding = (rowObj["neutralStanding"] as JsonValue)!.GetValue(); + return new FactionDefRow(id, displayName, minStanding, maxStanding, neutralStanding); + } + + 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} factions[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Faction 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/Factions/FactionDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs new file mode 100644 index 0000000..277adcf --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Factions; + +/// Adapter over (NEO-134). +public sealed class FactionDefinitionRegistry(FactionDefinitionCatalog catalog) : IFactionDefinitionRegistry +{ + /// + public bool TryGetDefinition(string? factionId, [NotNullWhen(true)] out FactionDefRow? definition) + { + if (factionId is null) + { + definition = null; + return false; + } + + if (catalog.ById.TryGetValue(factionId, out var row)) + { + definition = row; + return true; + } + + definition = null; + return false; + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.ById[id]); + } + + return list; + } +} diff --git a/server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs new file mode 100644 index 0000000..69ad348 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Factions; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +public interface IFactionDefinitionRegistry +{ + /// Attempts to resolve a faction by stable id (see faction-def.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? factionId, [NotNullWhen(true)] out FactionDefRow? definition); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs b/server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs new file mode 100644 index 0000000..c579c5c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs @@ -0,0 +1,95 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Factions; + +/// +/// Prototype E7M3 faction catalog gates (NEO-134), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M3_FACTION_* and _prototype_e7m3_faction_* gate functions. +/// +public static class PrototypeE7M3FactionCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_FACTION_IDS. + public static readonly FrozenSet ExpectedFactionIds = FrozenSet.ToFrozenSet( + [ + "prototype_faction_grid_operators", + "prototype_faction_rust_collective", + ], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_FACTION_FREEZE. + public static readonly FrozenDictionary ExpectedFactionFreeze = + new Dictionary(StringComparer.Ordinal) + { + ["prototype_faction_grid_operators"] = new( + "prototype_faction_grid_operators", + "Grid Operators", + -100, + 100, + 0), + ["prototype_faction_rust_collective"] = new( + "prototype_faction_rust_collective", + "Rust Collective", + -100, + 100, + 0), + }.ToFrozenDictionary(StringComparer.Ordinal); + + /// Returns a human-readable error if the prototype faction roster fails, otherwise . + public static string? TryGetRosterGateError(IReadOnlyDictionary factionIdToSourceFile) + { + var ids = factionIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedFactionIds)) + { + return + "error: prototype E7M3 expects exactly faction ids " + + $"[{string.Join(", ", ExpectedFactionIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error if faction rows diverge from the E7M3 freeze table, otherwise . + public static string? TryGetFreezeGateError(IReadOnlyDictionary rowsById) + { + foreach (var (fid, expected) in ExpectedFactionFreeze) + { + if (!rowsById.TryGetValue(fid, out var actual)) + return $"error: missing faction '{fid}'"; + + if (!RowsEqual(expected, actual)) + { + return + $"error: faction '{fid}' must match E7M3 freeze table " + + $"(expected displayName '{expected.DisplayName}', minStanding {expected.MinStanding}, " + + $"maxStanding {expected.MaxStanding}, neutralStanding {expected.NeutralStanding}; " + + $"got displayName '{actual.DisplayName}', minStanding {actual.MinStanding}, " + + $"maxStanding {actual.MaxStanding}, neutralStanding {actual.NeutralStanding})"; + } + } + + return null; + } + + /// Returns a human-readable error if standing band ordering fails, otherwise . + public static string? TryGetStandingBandGateError(IReadOnlyDictionary rowsById) + { + foreach (var (fid, row) in rowsById) + { + if (row.MinStanding > row.NeutralStanding || row.NeutralStanding > row.MaxStanding) + { + return + $"error: faction '{fid}' requires minStanding <= neutralStanding <= maxStanding " + + $"(got minStanding {row.MinStanding}, neutralStanding {row.NeutralStanding}, maxStanding {row.MaxStanding})"; + } + } + + return null; + } + + private static bool RowsEqual(FactionDefRow left, FactionDefRow right) => + left.DisplayName == right.DisplayName && + left.MinStanding == right.MinStanding && + left.MaxStanding == right.MaxStanding && + left.NeutralStanding == right.NeutralStanding; +} diff --git a/server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs b/server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs new file mode 100644 index 0000000..f1299ef --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Minimum standing gate on (NEO-134). +public sealed record FactionGateRuleRow(string FactionId, int MinStanding); diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs new file mode 100644 index 0000000..bc925b4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs @@ -0,0 +1,235 @@ +using System.Collections.Frozen; +using NeonSprawl.Server.Game.Encounters; + +namespace NeonSprawl.Server.Game.Quests; + +/// +/// Prototype E7M3 quest faction gates (NEO-134), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M3_* and _prototype_e7m3_* gate functions. +/// +public static class PrototypeE7M3QuestFactionRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID. + public const string GridContractGateFactionId = "prototype_faction_grid_operators"; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING. + public const int GridContractMinStanding = 15; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID. + public const string GridContractObjectiveItemId = "survey_drone_kit"; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_COMPLETION_BUNDLES. + public static readonly FrozenDictionary ExpectedCompletionBundles = + new Dictionary(StringComparer.Ordinal) + { + [PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = new( + [], + [new QuestSkillXpGrantRow("salvage", 25)], + []), + [PrototypeE7M1QuestCatalogRules.RefineIntroQuestId] = new( + [], + [new QuestSkillXpGrantRow("refine", 25)], + []), + [PrototypeE7M1QuestCatalogRules.CombatIntroQuestId] = new( + [], + [new QuestSkillXpGrantRow("salvage", 25)], + []), + [PrototypeE7M1QuestCatalogRules.ChainQuestId] = new( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 50)], + [new ReputationGrantRow("prototype_faction_grid_operators", 15)]), + [PrototypeE7M1QuestCatalogRules.GridContractQuestId] = new( + [], + [], + [new ReputationGrantRow("prototype_faction_rust_collective", 10)]), + }.ToFrozenDictionary(StringComparer.Ordinal); + + /// Returns a human-readable error if quest faction refs fail, otherwise . + public static string? TryGetFactionCrossRefError( + IReadOnlyDictionary rowsById, + IReadOnlySet knownFactionIds) + { + foreach (var (qid, row) in rowsById) + { + for (var gi = 0; gi < row.FactionGateRules.Count; gi++) + { + var rule = row.FactionGateRules[gi]; + if (!knownFactionIds.Contains(rule.FactionId)) + { + return + $"error: quest '{qid}' factionGateRules[{gi}]: factionId '{rule.FactionId}' " + + "is not in the frozen prototype faction catalog"; + } + } + + if (row.CompletionRewardBundle is null) + continue; + + for (var gi = 0; gi < row.CompletionRewardBundle.ReputationGrants.Count; gi++) + { + var grant = row.CompletionRewardBundle.ReputationGrants[gi]; + if (!knownFactionIds.Contains(grant.FactionId)) + { + return + $"error: quest '{qid}' completionRewardBundle.reputationGrants[{gi}]: factionId '{grant.FactionId}' " + + "is not in the frozen prototype faction catalog"; + } + } + } + + return null; + } + + /// Returns a human-readable error if bundle contents diverge from the E7M3 freeze table, otherwise . + public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary rowsById) + { + foreach (var (qid, expected) in ExpectedCompletionBundles) + { + if (!rowsById.TryGetValue(qid, out var row)) + return $"error: missing quest '{qid}'"; + + if (row.CompletionRewardBundle is null) + return $"error: quest '{qid}' must include completionRewardBundle object"; + + var actual = NormalizeBundle(row.CompletionRewardBundle); + var expectedNormalized = NormalizeBundle(expected); + if (!BundlesEqual(actual, expectedNormalized)) + { + return + $"error: quest '{qid}' completionRewardBundle must match E7M3 freeze table " + + $"(expected {FormatBundle(expectedNormalized)}, got {FormatBundle(actual)})"; + } + } + + return null; + } + + /// Returns a human-readable error if grid contract quest shape fails, otherwise . + public static string? TryGetGridContractShapeError(IReadOnlyDictionary rowsById) + { + var qid = PrototypeE7M1QuestCatalogRules.GridContractQuestId; + if (!rowsById.TryGetValue(qid, out var row)) + return $"error: missing quest '{qid}'"; + + if (!row.PrerequisiteQuestIds.SequenceEqual( + [PrototypeE7M1QuestCatalogRules.ChainQuestId], + StringComparer.Ordinal)) + { + var got = $"[{string.Join(", ", row.PrerequisiteQuestIds.Select(s => "'" + s + "'"))}]"; + return + $"error: quest '{qid}' prerequisiteQuestIds must be ['{PrototypeE7M1QuestCatalogRules.ChainQuestId}'], got {got}"; + } + + if (row.FactionGateRules.Count != 1 || + row.FactionGateRules[0].FactionId != GridContractGateFactionId || + row.FactionGateRules[0].MinStanding != GridContractMinStanding) + { + var expected = + $"[{{ factionId: '{GridContractGateFactionId}', minStanding: {GridContractMinStanding} }}]"; + var gotRules = string.Join( + ", ", + row.FactionGateRules.Select(r => $"{{ factionId: '{r.FactionId}', minStanding: {r.MinStanding} }}")); + return $"error: quest '{qid}' factionGateRules must be {expected}, got [{gotRules}]"; + } + + if (row.Steps.Count != 1) + return $"error: quest '{qid}' must have exactly one step"; + + var step = row.Steps[0]; + if (step.Objectives.Count != 1) + return $"error: quest '{qid}' must have exactly one objective"; + + var objective = step.Objectives[0]; + if (objective.Kind != "inventory_has_item") + { + return $"error: quest '{qid}' objective kind must be 'inventory_has_item'"; + } + + if (objective.ItemId != GridContractObjectiveItemId) + { + return + $"error: quest '{qid}' objective itemId must be '{GridContractObjectiveItemId}', got '{objective.ItemId}'"; + } + + if (objective.Quantity != 1) + { + return $"error: quest '{qid}' objective quantity must be 1, got {objective.Quantity}"; + } + + return null; + } + + private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) => + new( + bundle.ItemGrants + .OrderBy(g => g.ItemId, StringComparer.Ordinal) + .ThenBy(g => g.Quantity) + .ToArray(), + bundle.SkillXpGrants + .OrderBy(g => g.SkillId, StringComparer.Ordinal) + .ThenBy(g => g.Amount) + .ToArray(), + bundle.ReputationGrants + .OrderBy(g => g.FactionId, StringComparer.Ordinal) + .ThenBy(g => g.Amount) + .ToArray()); + + private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right) + { + if (left.ItemGrants.Length != right.ItemGrants.Length || + left.SkillXpGrants.Length != right.SkillXpGrants.Length || + left.ReputationGrants.Length != right.ReputationGrants.Length) + { + return false; + } + + for (var i = 0; i < left.ItemGrants.Length; i++) + { + if (left.ItemGrants[i].ItemId != right.ItemGrants[i].ItemId || + left.ItemGrants[i].Quantity != right.ItemGrants[i].Quantity) + { + return false; + } + } + + for (var i = 0; i < left.SkillXpGrants.Length; i++) + { + if (left.SkillXpGrants[i].SkillId != right.SkillXpGrants[i].SkillId || + left.SkillXpGrants[i].Amount != right.SkillXpGrants[i].Amount) + { + return false; + } + } + + for (var i = 0; i < left.ReputationGrants.Length; i++) + { + if (left.ReputationGrants[i].FactionId != right.ReputationGrants[i].FactionId || + left.ReputationGrants[i].Amount != right.ReputationGrants[i].Amount) + { + return false; + } + } + + return true; + } + + private static string FormatBundle(NormalizedBundle bundle) + { + var itemGrants = string.Join( + ", ", + bundle.ItemGrants.Select(g => $"{{ itemId: '{g.ItemId}', quantity: {g.Quantity} }}")); + var skillXpGrants = string.Join( + ", ", + bundle.SkillXpGrants.Select(g => $"{{ skillId: '{g.SkillId}', amount: {g.Amount} }}")); + var reputationGrants = string.Join( + ", ", + bundle.ReputationGrants.Select(g => $"{{ factionId: '{g.FactionId}', amount: {g.Amount} }}")); + return + $"{{ itemGrants: [{itemGrants}], skillXpGrants: [{skillXpGrants}], reputationGrants: [{reputationGrants}] }}"; + } + + private sealed record NormalizedBundle( + RewardGrantRow[] ItemGrants, + QuestSkillXpGrantRow[] SkillXpGrants, + ReputationGrantRow[] ReputationGrants); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs index 32b0430..0d9e49b 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Skills; @@ -25,6 +26,7 @@ public static class QuestCatalogServiceCollectionExtensions var recipeCatalog = sp.GetRequiredService(); var encounterCatalog = sp.GetRequiredService(); var skillCatalog = sp.GetRequiredService(); + var factionCatalog = sp.GetRequiredService(); var logger = sp.GetRequiredService() .CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog"); @@ -50,6 +52,7 @@ public static class QuestCatalogServiceCollectionExtensions var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + var knownFactionIds = factionCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); return QuestDefinitionCatalogLoader.Load( questsDir, @@ -62,6 +65,7 @@ public static class QuestCatalogServiceCollectionExtensions knownItemIds, knownRecipeIds, knownEncounterIds, + knownFactionIds, skillCatalog.ById, logger); }); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs index 984e314..43cc25a 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs @@ -1,12 +1,13 @@ namespace NeonSprawl.Server.Game.Quests; -/// Load-time quest row (NEO-113). +/// Load-time quest row (NEO-113, NEO-134). public sealed class QuestDefRow( string id, string displayName, IReadOnlyList prerequisiteQuestIds, IReadOnlyList steps, - QuestRewardBundleRow? completionRewardBundle = null) + QuestRewardBundleRow? completionRewardBundle = null, + IReadOnlyList? factionGateRules = null) { public string Id { get; } = id; @@ -17,4 +18,6 @@ public sealed class QuestDefRow( public IReadOnlyList Steps { get; } = steps; public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle; + + public IReadOnlyList FactionGateRules { get; } = factionGateRules ?? []; } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs index 1e456d1..77e23c5 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -8,7 +8,7 @@ using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Quests; -/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113, NEO-125). +/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113, NEO-125, NEO-134). public static class QuestDefinitionCatalogLoader { private static JsonSchema? _questObjectiveDefSchema; @@ -26,6 +26,7 @@ public static class QuestDefinitionCatalogLoader IReadOnlySet knownItemIds, IReadOnlySet knownRecipeIds, IReadOnlySet knownEncounterIds, + IReadOnlySet knownFactionIds, IReadOnlyDictionary skillDefsById, ILogger logger) { @@ -214,6 +215,27 @@ public static class QuestDefinitionCatalogLoader ThrowIfAny(errors); } + var factionCrossRef = PrototypeE7M3QuestFactionRules.TryGetFactionCrossRefError(rows, knownFactionIds); + if (factionCrossRef is not null) + { + errors.Add(factionCrossRef); + ThrowIfAny(errors); + } + + var e7m3BundleFreeze = PrototypeE7M3QuestFactionRules.TryGetCompletionBundleFreezeError(rows); + if (e7m3BundleFreeze is not null) + { + errors.Add(e7m3BundleFreeze); + ThrowIfAny(errors); + } + + var gridContractShape = PrototypeE7M3QuestFactionRules.TryGetGridContractShapeError(rows); + if (gridContractShape is not null) + { + errors.Add(gridContractShape); + ThrowIfAny(errors); + } + if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation( @@ -306,7 +328,27 @@ public static class QuestDefinitionCatalogLoader var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray); var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject); - return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle); + var factionGateRules = ParseFactionGateRules(rowObj["factionGateRules"] as JsonArray); + return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle, factionGateRules); + } + + private static IReadOnlyList ParseFactionGateRules(JsonArray? array) + { + if (array is null || array.Count == 0) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject ruleObj) + continue; + + var factionId = (ruleObj["factionId"] as JsonValue)!.GetValue(); + var minStanding = (ruleObj["minStanding"] as JsonValue)!.GetValue(); + rows.Add(new FactionGateRuleRow(factionId, minStanding)); + } + + return rows; } private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj) @@ -316,7 +358,27 @@ public static class QuestDefinitionCatalogLoader var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray); var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray); - return new QuestRewardBundleRow(itemGrants, skillXpGrants); + var reputationGrants = ParseReputationGrantRows(bundleObj["reputationGrants"] as JsonArray); + return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants); + } + + private static IReadOnlyList ParseReputationGrantRows(JsonArray? array) + { + if (array is null || array.Count == 0) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject grantObj) + continue; + + var factionId = (grantObj["factionId"] as JsonValue)!.GetValue(); + var amount = (grantObj["amount"] as JsonValue)!.GetValue(); + rows.Add(new ReputationGrantRow(factionId, amount)); + } + + return rows; } private static IReadOnlyList ParseItemGrantRows(JsonArray? array) diff --git a/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs index da7bd1c..a3ba65e 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs @@ -2,7 +2,16 @@ using NeonSprawl.Server.Game.Encounters; namespace NeonSprawl.Server.Game.Quests; -/// Composite completion rewards on (NEO-125). +/// Composite completion rewards on (NEO-125, NEO-134). public sealed record QuestRewardBundleRow( IReadOnlyList ItemGrants, - IReadOnlyList SkillXpGrants); + IReadOnlyList SkillXpGrants, + IReadOnlyList ReputationGrants) +{ + public QuestRewardBundleRow( + IReadOnlyList itemGrants, + IReadOnlyList skillXpGrants) + : this(itemGrants, skillXpGrants, []) + { + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs b/server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs new file mode 100644 index 0000000..f6ed3a9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Reputation grant row on (NEO-134). +public sealed record ReputationGrantRow(string FactionId, int Amount); diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index e3cccbc..50f87ad 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -154,4 +154,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of quests directory}/schemas/quest-objective-def.schema.json. /// public string? QuestObjectiveDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/factions directory. + /// + public string? FactionsDirectory { get; set; } + + /// + /// Optional override for faction-def.schema.json. + /// When unset, resolved as {parent of factions directory}/schemas/faction-def.schema.json. + /// + public string? FactionDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 3aebc75..1c28fd3 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -2,6 +2,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Interaction; @@ -23,6 +24,7 @@ builder.Services.AddPerkStateStore(builder.Configuration); builder.Services.AddAbilityCooldownStore(); builder.Services.AddSingleton(); builder.Services.AddSkillDefinitionCatalog(builder.Configuration); +builder.Services.AddFactionDefinitionCatalog(builder.Configuration); builder.Services.AddItemDefinitionCatalog(builder.Configuration); builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); @@ -39,6 +41,7 @@ builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/README.md b/server/README.md index c404a0c..e25af33 100644 --- a/server/README.md +++ b/server/README.md @@ -50,6 +50,19 @@ On startup the host loads every **`*_items.json`** under the items directory, va On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups (NEO-52). +## Faction catalog (`content/factions`, NEO-134) + +On startup the host loads every **`*_factions.json`** under the factions directory **after** the skill catalog and **before** the quest catalog, validates each row against **`content/schemas/faction-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate faction `id`** values across files, enforces the **prototype E7M3** two-faction roster, freeze table (display names + standing band), and standing-band ordering (`minStanding <= neutralStanding <= maxStanding`), then the quest loader cross-checks **`factionGateRules`** and **`completionRewardBundle.reputationGrants`** against loaded faction ids and enforces **prototype E7M3** completion bundle freeze (item + skill + reputation grants) and **grid-contract** quest shape (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:FactionsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/factions`**. | +| **`Content:FactionDefSchemaPath`** | Optional override for **`faction-def.schema.json`**. When unset, **`{parent of factions directory}/schemas/faction-def.schema.json`**. | + +**Docker / CI:** include **`content/factions`** and **`content/schemas/faction-def.schema.json`** in the mounted **`content/`** tree; set **`Content__FactionsDirectory`** when layout differs. + +On success, **Information** logs include the resolved factions directory path, distinct faction count, and catalog file count. Game code should use **`IFactionDefinitionRegistry`** for lookups (NEO-134). The catalog singleton remains for fail-fast startup only; do not inject **`FactionDefinitionCatalog`** in new game code. + ## Resource-node catalog (`content/resource-nodes`, NEO-58) On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster 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. @@ -135,7 +148,7 @@ On success, **Information** logs include the resolved encounters directory path, ## Quest catalog (`content/quests`, NEO-113) -On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, encounter, and **skill** catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`**, **`quest-objective-def.schema.json`**, **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, and **`reward-grant-row.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, enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate, and enforces **prototype E7M2** **`completionRewardBundle`** presence, freeze-table contents, item/skill cross-refs, and **`mission_reward`** allowlist on skill XP targets (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. +On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, encounter, **skill**, and **faction** catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`**, **`quest-objective-def.schema.json`**, **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**, **`faction-gate-rule.schema.json`**, and **`reputation-grant-row.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, enforces the **prototype E7M3** five-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate, enforces **prototype E7M2** **`completionRewardBundle`** presence, freeze-table contents (item + skill grants), item/skill cross-refs, and **`mission_reward`** allowlist on skill XP targets, and enforces **prototype E7M3** faction cross-refs on gates and reputation grants, E7M3 completion bundle freeze (including **`reputationGrants`**), and **grid-contract** quest shape (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 | |--------|---------| @@ -146,7 +159,7 @@ On startup the host loads every **`*_quests.json`** under the quests directory * Bundle-related schemas (**`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**) resolve as siblings under **`{parent of quests directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those three files next to the quest schemas (NEO-125). -**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**), and quest/bundle schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs. +**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**, **`content/factions`**), and quest/bundle/faction 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. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes.