using System.Collections.Frozen; using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Quests; 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(); const string 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 TryGetCrossRefError_ShouldReturnError_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 Load_ShouldThrow_WhenItemQuantityExceedsPrototypeBundleLimits() { // Arrange — qty 11 fails freeze before band-cap gate (same CI tamper path as validate_content.py) var (_, contractsDir, schemaPath) = CreateTempContentLayout(); var overCap = ValidPrototypeCatalogJson.Replace("\"quantity\": 5", "\"quantity\": 11", StringComparison.Ordinal); File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), overCap, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("completionRewardBundle must match E7M4 freeze table", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaFileMissing() { // Arrange var (_, contractsDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8); File.Delete(schemaPath); // Act var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); Assert.Contains(schemaPath, 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); } }