using Microsoft.AspNetCore.Mvc.Testing; using NeonSprawl.Server.Game.Encounters; namespace NeonSprawl.Server.Tests.Game.Encounters; public class EncounterDefinitionCatalogLoaderTests { private const string ValidPrototypeCatalogJson = """ { "schemaVersion": 1, "encounters": [ { "id": "prototype_combat_pocket", "displayName": "Prototype Combat Pocket", "completionCriteria": { "kind": "defeat_all_targets" }, "requiredNpcInstanceIds": [ "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" ], "rewardTableId": "prototype_combat_pocket_clear" } ] } """; private static readonly HashSet KnownRewardTableIds = ["prototype_combat_pocket_clear"]; private static (string Root, string EncountersDir, string SchemaPath) CreateTempContentLayout() { var root = Directory.CreateTempSubdirectory("neon-sprawl-encountercat-"); var encountersDir = Path.Combine(root.FullName, "content", "encounters"); var schemaDir = Path.Combine(root.FullName, "content", "schemas"); Directory.CreateDirectory(encountersDir); Directory.CreateDirectory(schemaDir); var schemaPath = Path.Combine(schemaDir, "encounter-def.schema.json"); File.Copy(EncounterCatalogTestPaths.DiscoverRepoEncounterDefSchemaPath(), schemaPath, overwrite: true); return (root.FullName, encountersDir, schemaPath); } private static void WriteCatalog(string encountersDir, string catalogJson) => File.WriteAllText(Path.Combine(encountersDir, "prototype_encounters.json"), catalogJson, Encoding.UTF8); private static EncounterDefinitionCatalog LoadCatalog( string encountersDir, string schemaPath, IReadOnlySet? knownRewardTableIds = null) => EncounterDefinitionCatalogLoader.Load( encountersDir, schemaPath, knownRewardTableIds ?? KnownRewardTableIds, NullLogger.Instance); [Fact] public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() { // Arrange var (_, encountersDir, schemaPath) = CreateTempContentLayout(); WriteCatalog(encountersDir, ValidPrototypeCatalogJson); // Act var catalog = LoadCatalog(encountersDir, schemaPath); // Assert Assert.Equal(1, catalog.DistinctEncounterCount); Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.True(catalog.TryGetEncounter("prototype_combat_pocket", out var encounter)); Assert.NotNull(encounter); Assert.Equal("Prototype Combat Pocket", encounter!.DisplayName); Assert.Equal("defeat_all_targets", encounter.CompletionCriteriaKind); Assert.Equal("prototype_combat_pocket_clear", encounter.RewardTableId); Assert.Equal(3, encounter.RequiredNpcInstanceIds.Count); } [Fact] public void Load_ShouldThrow_WhenEncountersIsNotArray() { // Arrange var (_, encountersDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(encountersDir, "bad_encounters.json"), """{"schemaVersion": 1, "encounters": "nope"}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_encounters.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("expected top-level 'encounters' array", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() { // Arrange var (_, encountersDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(encountersDir, "bad_encounters.json"), """{"schemaVersion": 2, "encounters": []}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDuplicateEncounterIdAcrossFiles() { // Arrange var (_, encountersDir, schemaPath) = CreateTempContentLayout(); WriteCatalog(encountersDir, ValidPrototypeCatalogJson); File.WriteAllText( Path.Combine(encountersDir, "extra_encounters.json"), """ { "schemaVersion": 1, "encounters": [ { "id": "prototype_combat_pocket", "displayName": "Duplicate Encounter", "completionCriteria": { "kind": "defeat_all_targets" }, "requiredNpcInstanceIds": [ "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" ], "rewardTableId": "prototype_combat_pocket_clear" } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate encounter id 'prototype_combat_pocket'", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenE5M3AllowlistMismatch() { // Arrange var (_, encountersDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(encountersDir, "prototype_encounters.json"), """ { "schemaVersion": 1, "encounters": [ { "id": "wrong_encounter_id", "displayName": "Wrong Encounter", "completionCriteria": { "kind": "defeat_all_targets" }, "requiredNpcInstanceIds": [ "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" ], "rewardTableId": "prototype_combat_pocket_clear" } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype E5M3 expects exactly encounter ids", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenRequiredNpcInstanceIdsMismatch() { // Arrange var (_, encountersDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(encountersDir, "prototype_encounters.json"), """ { "schemaVersion": 1, "encounters": [ { "id": "prototype_combat_pocket", "displayName": "Prototype Combat Pocket", "completionCriteria": { "kind": "defeat_all_targets" }, "requiredNpcInstanceIds": [ "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_unknown" ], "rewardTableId": "prototype_combat_pocket_clear" } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("requiredNpcInstanceIds must be exactly", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenRewardTableIdUnknown() { // Arrange var (_, encountersDir, schemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(encountersDir, "prototype_encounters.json"), """ { "schemaVersion": 1, "encounters": [ { "id": "prototype_combat_pocket", "displayName": "Prototype Combat Pocket", "completionCriteria": { "kind": "defeat_all_targets" }, "requiredNpcInstanceIds": [ "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" ], "rewardTableId": "missing_reward_table" } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("rewardTableId 'missing_reward_table' is not in reward table catalogs", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenRowViolatesSchema() { // Arrange var (_, encountersDir, schemaPath) = CreateTempContentLayout(); const string bad = """ { "schemaVersion": 1, "encounters": [ { "id": "prototype_combat_pocket", "displayName": "", "completionCriteria": { "kind": "defeat_all_targets" }, "requiredNpcInstanceIds": [ "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" ], "rewardTableId": "prototype_combat_pocket_clear" } ] } """; File.WriteAllText(Path.Combine(encountersDir, "bad_encounters.json"), bad, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_encounters.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("Encounter catalog validation failed", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaFileMissing() { // Arrange var (_, encountersDir, _) = CreateTempContentLayout(); WriteCatalog(encountersDir, ValidPrototypeCatalogJson); var missingSchema = Path.Combine(encountersDir, "missing-encounter-def.schema.json"); // Act var ex = Record.Exception(() => LoadCatalog(encountersDir, missingSchema)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal); } [Fact] public async Task Host_ShouldResolveCatalogsFromDi_WhenStartupSucceeds() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); using var client = factory.CreateClient(); // Act var response = await client.GetAsync("/health"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var rewardCatalog = factory.Services.GetRequiredService(); Assert.Equal(1, rewardCatalog.DistinctRewardTableCount); Assert.True(rewardCatalog.TryGetRewardTable("prototype_combat_pocket_clear", out var rewardTable)); Assert.NotNull(rewardTable); Assert.Equal(10, rewardTable!.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity); var encounterCatalog = factory.Services.GetRequiredService(); Assert.Equal(1, encounterCatalog.DistinctEncounterCount); Assert.True(encounterCatalog.TryGetEncounter("prototype_combat_pocket", out var encounter)); Assert.NotNull(encounter); Assert.Equal("prototype_combat_pocket_clear", encounter!.RewardTableId); } [Fact] public void Host_ShouldFailStartup_WhenRewardTablesDirectoryInvalid() { // Arrange var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-reward-tables-" + Guid.NewGuid().ToString("n")); Directory.CreateDirectory(badDir); try { // Act var ex = Record.Exception(() => { using var factory = new WebApplicationFactory().WithWebHostBuilder(b => b.UseSetting("Content:RewardTablesDirectory", badDir)); factory.CreateClient(); }); // Assert Assert.NotNull(ex); Assert.Contains("Reward table catalog validation failed", ex.ToString(), StringComparison.Ordinal); } finally { if (Directory.Exists(badDir)) Directory.Delete(badDir); } } [Fact] public void Host_ShouldFailStartup_WhenEncountersDirectoryInvalid() { // Arrange var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-encounters-" + Guid.NewGuid().ToString("n")); Directory.CreateDirectory(badDir); try { // Act var ex = Record.Exception(() => { using var factory = new WebApplicationFactory().WithWebHostBuilder(b => b.UseSetting("Content:EncountersDirectory", badDir)); factory.CreateClient(); }); // Assert Assert.NotNull(ex); Assert.Contains("Encounter catalog validation failed", ex.ToString(), StringComparison.Ordinal); } finally { if (Directory.Exists(badDir)) Directory.Delete(badDir); } } }