diff --git a/bruno/neon-sprawl-server/contract-catalog/Health after contract template catalog load.bru b/bruno/neon-sprawl-server/contract-catalog/Health after contract template catalog load.bru new file mode 100644 index 0000000..06a49c7 --- /dev/null +++ b/bruno/neon-sprawl-server/contract-catalog/Health after contract template catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (contract template catalog boot NEO-145) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-145 loads content/contracts/*_contract_templates.json at startup (fail-fast). No contract 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/contract-catalog/folder.bru b/bruno/neon-sprawl-server/contract-catalog/folder.bru new file mode 100644 index 0000000..43cced1 --- /dev/null +++ b/bruno/neon-sprawl-server/contract-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: contract-catalog +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs new file mode 100644 index 0000000..b047c25 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs @@ -0,0 +1,32 @@ +using NeonSprawl.Server.Game.Contracts; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +internal static class ContractCatalogTestPaths +{ + internal static string DiscoverRepoContractsDirectory() => + ContractCatalogPathResolution.TryDiscoverContractsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/contracts from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoContractTemplateSchemaPath() => + ContractCatalogPathResolution.ResolveContractTemplateSchemaPath( + DiscoverRepoContractsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); + + internal static string DiscoverRepoRewardGrantRowSchemaPath() => + ContractCatalogPathResolution.ResolveRewardGrantRowSchemaPath(DiscoverRepoContractsDirectory()); + + internal static string DiscoverRepoQuestSkillXpGrantSchemaPath() => + ContractCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(DiscoverRepoContractsDirectory()); + + internal static string DiscoverRepoQuestRewardBundleSchemaPath() => + ContractCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoContractsDirectory()); + + internal static string DiscoverRepoFactionGateRuleSchemaPath() => + ContractCatalogPathResolution.ResolveFactionGateRuleSchemaPath(DiscoverRepoContractsDirectory()); + + internal static string DiscoverRepoReputationGrantRowSchemaPath() => + ContractCatalogPathResolution.ResolveReputationGrantRowSchemaPath(DiscoverRepoContractsDirectory()); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs new file mode 100644 index 0000000..8222ad6 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs @@ -0,0 +1,277 @@ +using System.Collections.Frozen; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Tests.Game.Skills; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public class ContractTemplateCatalogLoaderTests +{ + private static readonly FrozenSet KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds; + + private static readonly FrozenSet KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds; + + private static readonly IReadOnlyDictionary RepoSkillDefs = LoadRepoSkillDefs(); + + private const string ValidPrototypeCatalogJson = + """ + { + "schemaVersion": 1, + "contractTemplates": [ + { + "id": "prototype_contract_clear_combat_pocket", + "displayName": "Clear Combat Pocket (Repeat)", + "zoneDifficultyBand": 1, + "objectiveKind": "encounter_clear", + "encounterTemplateId": "prototype_combat_pocket", + "minFactionStanding": { + "factionId": "prototype_faction_grid_operators", + "minStanding": 0 + }, + "completionRewardBundle": { + "itemGrants": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }], + "skillXpGrants": [{ "skillId": "salvage", "amount": 15 }] + } + } + ] + } + """; + + private static IReadOnlyDictionary LoadRepoSkillDefs() + { + var catalog = SkillDefinitionCatalogLoader.Load( + SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(), + SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(), + NullLogger.Instance); + return catalog.ById; + } + + private static ContractTemplateCatalog LoadCatalog( + string contractsDir, + string contractTemplateSchemaPath, + IReadOnlySet? knownEncounterIds = null, + IReadOnlySet? knownFactionIds = null, + IReadOnlyDictionary? skillDefsById = null) + { + return ContractTemplateCatalogLoader.Load( + contractsDir, + contractTemplateSchemaPath, + ContractCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), + ContractCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), + ContractCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), + ContractCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), + ContractCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), + knownEncounterIds ?? KnownEncounterIds, + knownFactionIds ?? KnownFactionIds, + skillDefsById ?? RepoSkillDefs, + NullLogger.Instance); + } + + private static (string Root, string ContractsDir, string SchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-contractcat-"); + var contractsDir = Path.Combine(root.FullName, "content", "contracts"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(contractsDir); + Directory.CreateDirectory(schemaDir); + var schemaPath = Path.Combine(schemaDir, "contract-template.schema.json"); + File.Copy(ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath(), schemaPath, overwrite: true); + File.Copy(ContractCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true); + File.Copy(ContractCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true); + File.Copy(ContractCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true); + File.Copy(ContractCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true); + File.Copy(ContractCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true); + return (root.FullName, contractsDir, schemaPath); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile() + { + // Arrange + var contractsDir = ContractCatalogTestPaths.DiscoverRepoContractsDirectory(); + var schemaPath = ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath(); + // Act + var catalog = LoadCatalog(contractsDir, schemaPath); + // Assert + Assert.Equal(PrototypeE7M4ContractCatalogRules.ExpectedTemplateIds.Count, catalog.DistinctTemplateCount); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, contractsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8); + // Act + var catalog = LoadCatalog(contractsDir, schemaPath); + // Assert + Assert.Equal(1, catalog.DistinctTemplateCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.ById.TryGetValue("prototype_contract_clear_combat_pocket", out var row)); + Assert.NotNull(row); + Assert.Equal("Clear Combat Pocket (Repeat)", row!.DisplayName); + Assert.Equal(1, row.ZoneDifficultyBand); + } + + [Fact] + public void Load_ShouldThrow_WhenContractTemplatesIsNotArray() + { + // Arrange + var (_, contractsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(contractsDir, "bad_contract_templates.json"), + """{"schemaVersion": 1, "contractTemplates": "nope"}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_contract_templates.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'contractTemplates' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles() + { + // Arrange + var (_, contractsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(contractsDir, "a_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8); + File.WriteAllText(Path.Combine(contractsDir, "b_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate contract template id", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenRosterGateFails() + { + // Arrange + var (_, contractsDir, schemaPath) = CreateTempContentLayout(); + var emptyCatalog = """{"schemaVersion": 1, "contractTemplates": []}"""; + File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), emptyCatalog, Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E7M4 expects exactly contract template ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenFreezeTableDiverges() + { + // Arrange + var (_, contractsDir, schemaPath) = CreateTempContentLayout(); + var badDisplay = ValidPrototypeCatalogJson.Replace("Clear Combat Pocket (Repeat)", "Wrong Name", StringComparison.Ordinal); + File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), badDisplay, Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("displayName must be", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenEncounterTemplateIdUnknown() + { + // Arrange + var (_, contractsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8); + var emptyEncounters = FrozenSet.Empty; + // Act + var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath, knownEncounterIds: emptyEncounters)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("encounterTemplateId 'prototype_combat_pocket'", ioe.Message, StringComparison.Ordinal); + Assert.Contains("is not in the loaded encounter catalog", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenBundleItemIdUnknown() + { + // Arrange + var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"]; + var badItemRow = prototype with + { + CompletionRewardBundle = new QuestRewardBundleRow( + [new RewardGrantRow("not_a_real_item", 5)], + prototype.CompletionRewardBundle.SkillXpGrants, + prototype.CompletionRewardBundle.ReputationGrants), + }; + var rows = new Dictionary(StringComparer.Ordinal) + { + [badItemRow.Id] = badItemRow, + }; + // Act + var error = PrototypeE7M4ContractCatalogRules.TryGetCrossRefError( + rows, + KnownEncounterIds, + KnownFactionIds, + RepoSkillDefs); + // Assert + Assert.NotNull(error); + Assert.Contains("itemId 'not_a_real_item'", error, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSkillXpMissingMissionRewardAllowlist() + { + // Arrange + var (_, contractsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8); + var salvageNoMission = new Dictionary(StringComparer.Ordinal) + { + ["salvage"] = new("salvage", "gather", "Salvage", ["activity"]), + ["refine"] = RepoSkillDefs["refine"], + ["intrusion"] = RepoSkillDefs["intrusion"], + }; + // Act + var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath, skillDefsById: salvageNoMission)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void TryGetBandCapGateError_ShouldReturnError_WhenItemQuantityExceedsBandCap() + { + // Arrange + var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"]; + var overCapRow = prototype with + { + CompletionRewardBundle = new QuestRewardBundleRow( + [new RewardGrantRow("scrap_metal_bulk", 11)], + prototype.CompletionRewardBundle.SkillXpGrants, + prototype.CompletionRewardBundle.ReputationGrants), + }; + var rows = new Dictionary(StringComparer.Ordinal) + { + [overCapRow.Id] = overCapRow, + }; + // Act + var error = PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rows); + // Assert + Assert.NotNull(error); + Assert.Contains("quantity 11 exceeds band 1 cap 10", error, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenMissingContractsDirectory() + { + // Arrange + var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-missing-contracts-" + Guid.NewGuid().ToString("N")); + var schemaPath = ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath(); + // Act + var ex = Record.Exception(() => LoadCatalog(missingDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs new file mode 100644 index 0000000..89fc36c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs @@ -0,0 +1,113 @@ +using System.Collections.Frozen; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Tests; +using NeonSprawl.Server.Tests.Game.Skills; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public class ContractTemplateRegistryTests +{ + private static ContractTemplateRegistry CreateRegistryFromRows(IReadOnlyDictionary byId) + { + var catalog = new ContractTemplateCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); + return new ContractTemplateRegistry(catalog); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrue_WhenIdExists() + { + // Arrange + var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"]; + var rows = new Dictionary(StringComparer.Ordinal) + { + [expected.Id] = expected, + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("prototype_contract_clear_combat_pocket", out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal("Clear Combat Pocket (Repeat)", def!.DisplayName); + Assert.Equal("prototype_combat_pocket", def.EncounterTemplateId); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenTemplateIdIsNull() + { + // Arrange + var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"]; + var rows = new Dictionary(StringComparer.Ordinal) + { + [expected.Id] = expected, + }; + 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 expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"]; + var rows = new Dictionary(StringComparer.Ordinal) + { + [expected.Id] = expected, + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("not_a_real_template", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleTemplates() + { + // Arrange + var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"]; + var other = prototype with { Id = "zzz_other_template" }; + var rows = new Dictionary(StringComparer.Ordinal) + { + [other.Id] = other, + [prototype.Id] = prototype, + }; + var registry = CreateRegistryFromRows(rows); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(2, list.Count); + Assert.Equal("prototype_contract_clear_combat_pocket", list[0].Id); + Assert.Equal("zzz_other_template", list[1].Id); + } + + [Fact] + public async Task Host_ShouldResolveContractCatalogFromDi_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_contract_clear_combat_pocket", out var template); + // Assert + Assert.True(found); + Assert.NotNull(template); + Assert.Equal("Clear Combat Pocket (Repeat)", template!.DisplayName); + Assert.Equal("encounter_clear", template.ObjectiveKind); + Assert.Equal("prototype_combat_pocket", template.EncounterTemplateId); + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 4f9a577..cd8fb67 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; @@ -66,6 +67,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactoryResolves contract catalog paths for local dev, tests, and container layouts (NEO-145). +public static class ContractCatalogPathResolution +{ + /// Walks and parents for an existing content/contracts directory. + public static string? TryDiscoverContractsDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "contracts"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the contracts catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveContractsDirectory(string? configuredContractsDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredContractsDirectory)) + { + var discovered = TryDiscoverContractsDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:ContractsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/contracts'). " + + "Set Content:ContractsDirectory in configuration or environment (e.g. Content__ContractsDirectory)."); + } + + var trimmed = configuredContractsDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single contract template row (Draft 2020-12). + public static string ResolveContractTemplateSchemaPath( + string contractsDirectory, + 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(contractsDirectory, "..", "schemas", "contract-template.schema.json")); + } + + /// Resolves JSON Schema path for a reward grant row (shared by quest bundles and contract templates). + public static string ResolveRewardGrantRowSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reward-grant-row.schema.json")); + + /// Resolves JSON Schema path for a quest skill XP grant row. + public static string ResolveQuestSkillXpGrantSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json")); + + /// Resolves JSON Schema path for a completion reward bundle. + public static string ResolveQuestRewardBundleSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-reward-bundle.schema.json")); + + /// Resolves JSON Schema path for a faction gate rule row. + public static string ResolveFactionGateRuleSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "faction-gate-rule.schema.json")); + + /// Resolves JSON Schema path for a reputation grant row. + public static string ResolveReputationGrantRowSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reputation-grant-row.schema.json")); +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..813bb8a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Contracts; + +/// DI registration for the fail-fast contract template catalog (NEO-145). +public static class ContractCatalogServiceCollectionExtensions +{ + /// Binds and registers and as singletons. + public static IServiceCollection AddContractTemplateCatalog(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 encounterCatalog = sp.GetRequiredService(); + var skillCatalog = sp.GetRequiredService(); + var factionCatalog = sp.GetRequiredService(); + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Contracts.ContractCatalog"); + + var contractsDir = ContractCatalogPathResolution.ResolveContractsDirectory( + opts.ContractsDirectory, + hostEnv.ContentRootPath); + var contractTemplateSchemaPath = ContractCatalogPathResolution.ResolveContractTemplateSchemaPath( + contractsDir, + opts.ContractTemplateSchemaPath, + hostEnv.ContentRootPath); + var rewardGrantRowSchemaPath = ContractCatalogPathResolution.ResolveRewardGrantRowSchemaPath(contractsDir); + var questSkillXpGrantSchemaPath = ContractCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(contractsDir); + var questRewardBundleSchemaPath = ContractCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(contractsDir); + var factionGateRuleSchemaPath = ContractCatalogPathResolution.ResolveFactionGateRuleSchemaPath(contractsDir); + var reputationGrantRowSchemaPath = ContractCatalogPathResolution.ResolveReputationGrantRowSchemaPath(contractsDir); + + var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + var knownFactionIds = factionCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + + return ContractTemplateCatalogLoader.Load( + contractsDir, + contractTemplateSchemaPath, + rewardGrantRowSchemaPath, + questSkillXpGrantSchemaPath, + questRewardBundleSchemaPath, + factionGateRuleSchemaPath, + reputationGrantRowSchemaPath, + knownEncounterIds, + knownFactionIds, + skillCatalog.ById, + logger); + }); + + services.AddSingleton(sp => + new ContractTemplateRegistry(sp.GetRequiredService())); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs new file mode 100644 index 0000000..fdbc861 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs @@ -0,0 +1,27 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Contracts; + +/// In-memory contract template catalog loaded at startup (NEO-145). Game code should prefer for lookups. +public sealed class ContractTemplateCatalog +{ + public ContractTemplateCatalog( + string contractsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) + { + ContractsDirectory = contractsDirectory; + ById = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + CatalogJsonFileCount = catalogJsonFileCount; + } + + /// Absolute path to the directory that was enumerated for *_contract_templates.json catalogs. + public string ContractsDirectory { get; } + + public IReadOnlyDictionary ById { get; } + + public int DistinctTemplateCount => ById.Count; + + /// Number of *_contract_templates.json files under . + public int CatalogJsonFileCount { get; } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs new file mode 100644 index 0000000..52126e8 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs @@ -0,0 +1,353 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Loads and validates content/contracts/*_contract_templates.json using the same rules as scripts/validate_content.py (NEO-145). +public static class ContractTemplateCatalogLoader +{ + private static JsonSchema? _contractTemplateSchema; + + /// Loads catalogs from disk or throws with actionable messages. + public static ContractTemplateCatalog Load( + string contractsDirectory, + string contractTemplateSchemaPath, + string rewardGrantRowSchemaPath, + string questSkillXpGrantSchemaPath, + string questRewardBundleSchemaPath, + string factionGateRuleSchemaPath, + string reputationGrantRowSchemaPath, + IReadOnlySet knownEncounterIds, + IReadOnlySet knownFactionIds, + IReadOnlyDictionary skillDefsById, + ILogger logger) + { + contractsDirectory = Path.GetFullPath(contractsDirectory); + contractTemplateSchemaPath = Path.GetFullPath(contractTemplateSchemaPath); + rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath); + questSkillXpGrantSchemaPath = Path.GetFullPath(questSkillXpGrantSchemaPath); + questRewardBundleSchemaPath = Path.GetFullPath(questRewardBundleSchemaPath); + factionGateRuleSchemaPath = Path.GetFullPath(factionGateRuleSchemaPath); + reputationGrantRowSchemaPath = Path.GetFullPath(reputationGrantRowSchemaPath); + + var errors = new List(); + + if (!File.Exists(contractTemplateSchemaPath)) + errors.Add($"error: missing schema file {contractTemplateSchemaPath}"); + + if (!File.Exists(rewardGrantRowSchemaPath)) + errors.Add($"error: missing schema file {rewardGrantRowSchemaPath}"); + + if (!File.Exists(questSkillXpGrantSchemaPath)) + errors.Add($"error: missing schema file {questSkillXpGrantSchemaPath}"); + + if (!File.Exists(questRewardBundleSchemaPath)) + errors.Add($"error: missing schema file {questRewardBundleSchemaPath}"); + + if (!File.Exists(factionGateRuleSchemaPath)) + errors.Add($"error: missing schema file {factionGateRuleSchemaPath}"); + + if (!File.Exists(reputationGrantRowSchemaPath)) + errors.Add($"error: missing schema file {reputationGrantRowSchemaPath}"); + + if (!Directory.Exists(contractsDirectory)) + errors.Add($"error: missing directory {contractsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(contractsDirectory, "*_contract_templates.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_contract_templates.json files under {contractsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + EnsureContractSchemasLoaded( + contractTemplateSchemaPath, + rewardGrantRowSchemaPath, + questSkillXpGrantSchemaPath, + questRewardBundleSchemaPath, + factionGateRuleSchemaPath, + reputationGrantRowSchemaPath); + var templateSchema = _contractTemplateSchema!; + + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var templateIdToSourceFile = 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 templatesNode = rootObj["contractTemplates"]; + if (templatesNode is not JsonArray templatesArray) + { + errors.Add($"error: {path}: expected top-level 'contractTemplates' array"); + continue; + } + + for (var i = 0; i < templatesArray.Count; i++) + { + var item = templatesArray[i]; + if (item is not JsonObject rowObj) + { + errors.Add($"error: {path}: contractTemplates[{i}] must be an object"); + continue; + } + + var eval = templateSchema.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} contractTemplates[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var tid = (rowObj["id"] as JsonValue)?.GetValue(); + if (tid is not null && eval.IsValid) + { + if (templateIdToSourceFile.TryGetValue(tid, out var prevPath)) + { + errors.Add($"error: duplicate contract template id '{tid}' in {prevPath} and {path}"); + continue; + } + + templateIdToSourceFile[tid] = path; + rows[tid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var roster = PrototypeE7M4ContractCatalogRules.TryGetRosterGateError(templateIdToSourceFile); + if (roster is not null) + { + errors.Add(roster); + ThrowIfAny(errors); + } + + var freeze = PrototypeE7M4ContractCatalogRules.TryGetFreezeGateError(rows); + if (freeze is not null) + { + errors.Add(freeze); + ThrowIfAny(errors); + } + + var crossRef = PrototypeE7M4ContractCatalogRules.TryGetCrossRefError( + rows, + knownEncounterIds, + knownFactionIds, + skillDefsById); + if (crossRef is not null) + { + errors.Add(crossRef); + ThrowIfAny(errors); + } + + var bandCap = PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rows); + if (bandCap is not null) + { + errors.Add(bandCap); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded contract template catalog from {ContractsDirectory}: {TemplateCount} template(s) across {CatalogFileCount} JSON catalog file(s).", + contractsDirectory, + rows.Count, + jsonFiles.Length); + } + + return new ContractTemplateCatalog(contractsDirectory, rows, jsonFiles.Length); + } + + private static ContractTemplateRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var zoneDifficultyBand = (rowObj["zoneDifficultyBand"] as JsonValue)!.GetValue(); + var objectiveKind = (rowObj["objectiveKind"] as JsonValue)!.GetValue(); + var encounterTemplateId = (rowObj["encounterTemplateId"] as JsonValue)!.GetValue(); + var minStandingObj = rowObj["minFactionStanding"] as JsonObject; + var factionId = (minStandingObj!["factionId"] as JsonValue)!.GetValue(); + var minStanding = (minStandingObj["minStanding"] as JsonValue)!.GetValue(); + var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject)!; + return new ContractTemplateRow( + id, + displayName, + zoneDifficultyBand, + objectiveKind, + encounterTemplateId, + new FactionGateRuleRow(factionId, minStanding), + completionRewardBundle); + } + + private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj) + { + if (bundleObj is null) + return null; + + var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray); + var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray); + 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) + { + 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 itemId = (grantObj["itemId"] as JsonValue)!.GetValue(); + var quantity = (grantObj["quantity"] as JsonValue)!.GetValue(); + rows.Add(new RewardGrantRow(itemId, quantity)); + } + + return rows; + } + + private static IReadOnlyList ParseSkillXpGrantRows(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 skillId = (grantObj["skillId"] as JsonValue)!.GetValue(); + var amount = (grantObj["amount"] as JsonValue)!.GetValue(); + rows.Add(new QuestSkillXpGrantRow(skillId, amount)); + } + + return rows; + } + + 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} contractTemplates[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + /// Registers bundle + gate schemas in once per process (NEO-145). + private static void EnsureContractSchemasLoaded( + string contractTemplateSchemaPath, + string rewardGrantRowSchemaPath, + string questSkillXpGrantSchemaPath, + string questRewardBundleSchemaPath, + string factionGateRuleSchemaPath, + string reputationGrantRowSchemaPath) + { + if (_contractTemplateSchema is not null) + return; + + CatalogSchemaRegistry.GetOrRegisterFromFile(factionGateRuleSchemaPath); + CatalogSchemaRegistry.GetOrRegisterFromFile(reputationGrantRowSchemaPath); + CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath); + CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath); + CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath); + _contractTemplateSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(contractTemplateSchemaPath); + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Contract template 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/Contracts/ContractTemplateRegistry.cs b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRegistry.cs new file mode 100644 index 0000000..b9056f7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRegistry.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Adapter over (NEO-145). +public sealed class ContractTemplateRegistry(ContractTemplateCatalog catalog) : IContractTemplateRegistry +{ + /// + public bool TryGetDefinition(string? templateId, [NotNullWhen(true)] out ContractTemplateRow? definition) + { + if (templateId is null) + { + definition = null; + return false; + } + + if (catalog.ById.TryGetValue(templateId, 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/Contracts/ContractTemplateRow.cs b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRow.cs new file mode 100644 index 0000000..0a40adc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRow.cs @@ -0,0 +1,13 @@ +using NeonSprawl.Server.Game.Quests; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Load-time contract template row (NEO-145). +public sealed record ContractTemplateRow( + string Id, + string DisplayName, + int ZoneDifficultyBand, + string ObjectiveKind, + string EncounterTemplateId, + FactionGateRuleRow MinFactionStanding, + QuestRewardBundleRow CompletionRewardBundle); diff --git a/server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs b/server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs new file mode 100644 index 0000000..a4fe10d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Contracts; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +public interface IContractTemplateRegistry +{ + /// Attempts to resolve a contract template by stable id. Unknown ids and null return false without throwing. + bool TryGetDefinition(string? templateId, [NotNullWhen(true)] out ContractTemplateRow? definition); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs b/server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs new file mode 100644 index 0000000..a0cb3f7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs @@ -0,0 +1,314 @@ +using System.Collections.Frozen; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Contracts; + +/// +/// Prototype E7M4 contract template catalog gates (NEO-145), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M4_* and _prototype_e7m4_* gate functions. +/// +public static class PrototypeE7M4ContractCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M4_TEMPLATE_IDS. + public static readonly FrozenSet ExpectedTemplateIds = FrozenSet.ToFrozenSet( + ["prototype_contract_clear_combat_pocket"], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M4_BAND_CAPS; NEO-147 ports to issue-time validation. + public static readonly FrozenDictionary BandCapsByDifficultyBand = + new Dictionary + { + [1] = new BandCapPolicy(MaxItemQuantity: 10, MaxSkillXpAmount: 25, MaxReputationAmount: 10), + }.ToFrozenDictionary(); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M4_TEMPLATE_FREEZE. + public static readonly FrozenDictionary ExpectedTemplateFreeze = + new Dictionary(StringComparer.Ordinal) + { + ["prototype_contract_clear_combat_pocket"] = new( + "prototype_contract_clear_combat_pocket", + "Clear Combat Pocket (Repeat)", + 1, + "encounter_clear", + "prototype_combat_pocket", + new FactionGateRuleRow("prototype_faction_grid_operators", 0), + new QuestRewardBundleRow( + [new RewardGrantRow("scrap_metal_bulk", 5)], + [new QuestSkillXpGrantRow("salvage", 15)], + [])), + }.ToFrozenDictionary(StringComparer.Ordinal); + + /// Returns a human-readable error if the prototype template roster fails, otherwise . + public static string? TryGetRosterGateError(IReadOnlyDictionary templateIdToSourceFile) + { + var ids = templateIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedTemplateIds)) + { + return + "error: prototype E7M4 expects exactly contract template ids " + + $"[{string.Join(", ", ExpectedTemplateIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error if template rows diverge from the E7M4 freeze table, otherwise . + public static string? TryGetFreezeGateError(IReadOnlyDictionary rowsById) + { + foreach (var (tid, expected) in ExpectedTemplateFreeze) + { + if (!rowsById.TryGetValue(tid, out var actual)) + return $"error: missing contract template '{tid}'"; + + if (actual.DisplayName != expected.DisplayName) + { + return + $"error: contract template '{tid}' displayName must be '{expected.DisplayName}', got '{actual.DisplayName}'"; + } + + if (actual.ZoneDifficultyBand != expected.ZoneDifficultyBand) + { + return + $"error: contract template '{tid}' zoneDifficultyBand must be {expected.ZoneDifficultyBand}, got {actual.ZoneDifficultyBand}"; + } + + if (actual.ObjectiveKind != expected.ObjectiveKind) + { + return + $"error: contract template '{tid}' objectiveKind must be '{expected.ObjectiveKind}', got '{actual.ObjectiveKind}'"; + } + + if (actual.EncounterTemplateId != expected.EncounterTemplateId) + { + return + $"error: contract template '{tid}' encounterTemplateId must be '{expected.EncounterTemplateId}', got '{actual.EncounterTemplateId}'"; + } + + if (actual.MinFactionStanding.FactionId != expected.MinFactionStanding.FactionId || + actual.MinFactionStanding.MinStanding != expected.MinFactionStanding.MinStanding) + { + return + $"error: contract template '{tid}' minFactionStanding must be {{ factionId: '{expected.MinFactionStanding.FactionId}', minStanding: {expected.MinFactionStanding.MinStanding} }}, " + + $"got {{ factionId: '{actual.MinFactionStanding.FactionId}', minStanding: {actual.MinFactionStanding.MinStanding} }}"; + } + + var actualBundle = NormalizeBundle(actual.CompletionRewardBundle); + var expectedBundle = NormalizeBundle(expected.CompletionRewardBundle); + if (!BundlesEqual(actualBundle, expectedBundle)) + { + return + $"error: contract template '{tid}' completionRewardBundle must match E7M4 freeze table " + + $"(expected {FormatBundle(expectedBundle)}, got {FormatBundle(actualBundle)})"; + } + } + + return null; + } + + /// Returns a human-readable error when cross-refs fail, otherwise . + public static string? TryGetCrossRefError( + IReadOnlyDictionary rowsById, + IReadOnlySet knownEncounterIds, + IReadOnlySet knownFactionIds, + IReadOnlyDictionary skillDefsById) + { + foreach (var tid in rowsById.Keys.Order(StringComparer.Ordinal)) + { + var row = rowsById[tid]; + if (!knownEncounterIds.Contains(row.EncounterTemplateId)) + { + return + $"error: contract template '{tid}' encounterTemplateId '{row.EncounterTemplateId}' " + + "is not in the loaded encounter catalog"; + } + + if (!knownFactionIds.Contains(row.MinFactionStanding.FactionId)) + { + return + $"error: contract template '{tid}' minFactionStanding.factionId " + + $"'{row.MinFactionStanding.FactionId}' is not in the frozen prototype faction catalog"; + } + + var bundle = row.CompletionRewardBundle; + for (var gi = 0; gi < bundle.ItemGrants.Count; gi++) + { + var grant = bundle.ItemGrants[gi]; + if (!PrototypeSlice1ItemCatalogRules.ExpectedItemIds.Contains(grant.ItemId)) + { + return + $"error: contract template '{tid}' completionRewardBundle.itemGrants[{gi}]: itemId " + + $"'{grant.ItemId}' is not in the frozen prototype item catalog"; + } + } + + for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++) + { + var grant = bundle.SkillXpGrants[gi]; + if (!PrototypeSlice1SkillCatalogRules.ExpectedSkillIds.Contains(grant.SkillId)) + { + return + $"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " + + $"'{grant.SkillId}' is not in the frozen prototype skill catalog"; + } + + skillDefsById.TryGetValue(grant.SkillId, out var skill); + var allowedKinds = skill?.AllowedXpSourceKinds ?? []; + if (!allowedKinds.Contains(PrototypeE7M2QuestCatalogRules.MissionRewardSourceKind, StringComparer.Ordinal)) + { + var kindsDisplay = allowedKinds.Count == 0 + ? "[]" + : $"[{string.Join(", ", allowedKinds.Select(k => $"'{k}'"))}]"; + return + $"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " + + $"'{grant.SkillId}' must allow sourceKind '{PrototypeE7M2QuestCatalogRules.MissionRewardSourceKind}' " + + $"in allowedXpSourceKinds (got {kindsDisplay})"; + } + } + + for (var gi = 0; gi < bundle.ReputationGrants.Count; gi++) + { + var grant = bundle.ReputationGrants[gi]; + if (!knownFactionIds.Contains(grant.FactionId)) + { + return + $"error: contract template '{tid}' completionRewardBundle.reputationGrants[{gi}]: factionId " + + $"'{grant.FactionId}' is not in the frozen prototype faction catalog"; + } + } + } + + return null; + } + + /// Returns a human-readable error if a template bundle exceeds band caps, otherwise . + public static string? TryGetBandCapGateError(IReadOnlyDictionary rowsById) + { + foreach (var tid in rowsById.Keys.Order(StringComparer.Ordinal)) + { + var row = rowsById[tid]; + if (!BandCapsByDifficultyBand.TryGetValue(row.ZoneDifficultyBand, out var caps)) + { + return + $"error: contract template '{tid}' zoneDifficultyBand {row.ZoneDifficultyBand} has no " + + "prototype economy cap policy"; + } + + var bundle = row.CompletionRewardBundle; + for (var gi = 0; gi < bundle.ItemGrants.Count; gi++) + { + var grant = bundle.ItemGrants[gi]; + if (grant.Quantity > caps.MaxItemQuantity) + { + return + $"error: contract template '{tid}' completionRewardBundle.itemGrants[{gi}] " + + $"quantity {grant.Quantity} exceeds band {row.ZoneDifficultyBand} cap {caps.MaxItemQuantity}"; + } + } + + for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++) + { + var grant = bundle.SkillXpGrants[gi]; + if (grant.Amount > caps.MaxSkillXpAmount) + { + return + $"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}] " + + $"amount {grant.Amount} exceeds band {row.ZoneDifficultyBand} cap {caps.MaxSkillXpAmount}"; + } + } + + for (var gi = 0; gi < bundle.ReputationGrants.Count; gi++) + { + var grant = bundle.ReputationGrants[gi]; + if (Math.Abs(grant.Amount) > caps.MaxReputationAmount) + { + return + $"error: contract template '{tid}' completionRewardBundle.reputationGrants[{gi}] " + + $"amount {grant.Amount} exceeds band {row.ZoneDifficultyBand} rep cap {caps.MaxReputationAmount}"; + } + } + } + + 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); +} + +/// Prototype economy caps keyed by zone difficulty band (NEO-145). +public sealed record BandCapPolicy(int MaxItemQuantity, int MaxSkillXpAmount, int MaxReputationAmount); diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 50f87ad..a73508e 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -166,4 +166,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of factions directory}/schemas/faction-def.schema.json. /// public string? FactionDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/contracts directory. + /// + public string? ContractsDirectory { get; set; } + + /// + /// Optional override for contract-template.schema.json. + /// When unset, resolved as {parent of contracts directory}/schemas/contract-template.schema.json. + /// + public string? ContractTemplateSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 6aa6483..0f6a688 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -1,5 +1,6 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; @@ -32,6 +33,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration); +builder.Services.AddContractTemplateCatalog(builder.Configuration); builder.Services.AddPlayerQuestStateStore(builder.Configuration); builder.Services.AddRewardDeliveryStore(); builder.Services.AddFactionStandingStores(builder.Configuration); @@ -51,6 +53,7 @@ _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/README.md b/server/README.md index a1d05f0..f608cd1 100644 --- a/server/README.md +++ b/server/README.md @@ -254,6 +254,21 @@ Bundle-related schemas (**`quest-reward-bundle.schema.json`**, **`quest-skill-xp 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. +## Contract template catalog (`content/contracts`, NEO-145) + +On startup the host loads every **`*_contract_templates.json`** under the contracts directory **after** the item, recipe, encounter, skill, faction, and quest catalogs, validates each row against **`content/schemas/contract-template.schema.json`** (with **`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 template `id`** values across files, cross-checks **`encounterTemplateId`** against the loaded encounter catalog and bundle item/skill/faction refs against frozen prototype catalogs, enforces the **prototype E7M4** one-template roster and freeze table (including empty **`reputationGrants`**), and enforces **band-1 economy caps** (item qty ≤ **10**, skill XP ≤ **25**, rep ≤ **10** per grant row — 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:ContractsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/contracts`**. | +| **`Content:ContractTemplateSchemaPath`** | Optional override for **`contract-template.schema.json`**. When unset, **`{parent of contracts directory}/schemas/contract-template.schema.json`**. | + +Bundle- and gate-related schemas resolve as siblings under **`{parent of contracts directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those files next to the contract template schema (NEO-145). + +**Docker / CI:** include **`content/contracts`**, upstream catalogs (**`content/encounters`**, **`content/skills`**, **`content/factions`**, **`content/items`**), and contract/bundle/faction schemas in the mounted **`content/`** tree; set **`Content__ContractsDirectory`** when layout differs. + +On success, **Information** logs include the resolved contracts directory path, distinct template count, and catalog file count. Game code should use **`IContractTemplateRegistry`** for lookups (`TryGetDefinition`, `GetDefinitionsInIdOrder`; NEO-145). The catalog singleton remains for fail-fast startup only; do not inject **`ContractTemplateCatalog`** in new game code. + ## Quest definitions (NEO-115) **`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.