using System.Text; using System.Text.Json.Nodes; using Microsoft.Extensions.Logging.Abstractions; using NeonSprawl.Server.Game.Encounters; using Xunit; namespace NeonSprawl.Server.Tests.Game.Encounters; public class RewardTableDefinitionCatalogLoaderTests { private const string ValidPrototypeCatalogJson = """ { "schemaVersion": 1, "rewardTables": [ { "id": "prototype_combat_pocket_clear", "displayName": "Prototype Combat Pocket Clear", "fixedGrants": [ { "itemId": "scrap_metal_bulk", "quantity": 10 }, { "itemId": "contract_handoff_token", "quantity": 1 } ] } ] } """; private static (string Root, string RewardTablesDir, string TableSchemaPath, string GrantRowSchemaPath) CreateTempContentLayout() { var root = Directory.CreateTempSubdirectory("neon-sprawl-rewardtablecat-"); var rewardTablesDir = Path.Combine(root.FullName, "content", "reward-tables"); var schemaDir = Path.Combine(root.FullName, "content", "schemas"); Directory.CreateDirectory(rewardTablesDir); Directory.CreateDirectory(schemaDir); var tableSchemaPath = Path.Combine(schemaDir, "reward-table.schema.json"); var grantRowSchemaPath = Path.Combine(schemaDir, "reward-grant-row.schema.json"); File.Copy(EncounterCatalogTestPaths.DiscoverRepoRewardTableDefSchemaPath(), tableSchemaPath, overwrite: true); File.Copy(EncounterCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), grantRowSchemaPath, overwrite: true); return (root.FullName, rewardTablesDir, tableSchemaPath, grantRowSchemaPath); } private static void WriteCatalog(string rewardTablesDir, string catalogJson) => File.WriteAllText(Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), catalogJson, Encoding.UTF8); private static RewardTableDefinitionCatalog LoadCatalog( string rewardTablesDir, string tableSchemaPath, string grantRowSchemaPath, IReadOnlySet? knownItemIds = null) => RewardTableDefinitionCatalogLoader.Load( rewardTablesDir, tableSchemaPath, grantRowSchemaPath, knownItemIds ?? EncounterCatalogTestPaths.Slice1KnownItemIds, NullLogger.Instance); [Fact] public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() { // Arrange var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); WriteCatalog(rewardTablesDir, ValidPrototypeCatalogJson); // Act var catalog = LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath); // Assert Assert.Equal(1, catalog.DistinctRewardTableCount); Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.True(catalog.TryGetRewardTable("prototype_combat_pocket_clear", out var table)); Assert.NotNull(table); Assert.Equal("Prototype Combat Pocket Clear", table!.DisplayName); Assert.Equal(2, table.FixedGrants.Count); Assert.Equal("scrap_metal_bulk", table.FixedGrants[0].ItemId); Assert.Equal(10, table.FixedGrants[0].Quantity); Assert.Equal("contract_handoff_token", table.FixedGrants[1].ItemId); Assert.Equal(1, table.FixedGrants[1].Quantity); } [Fact] public void Load_ShouldThrow_WhenRewardTablesIsNotArray() { // Arrange var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(rewardTablesDir, "bad_reward_tables.json"), """{"schemaVersion": 1, "rewardTables": "nope"}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("bad_reward_tables.json", ioe.Message, StringComparison.Ordinal); Assert.Contains("expected top-level 'rewardTables' array", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() { // Arrange var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(rewardTablesDir, "bad_reward_tables.json"), """{"schemaVersion": 2, "rewardTables": []}""", Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDuplicateRewardTableIdAcrossFiles() { // Arrange var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); WriteCatalog(rewardTablesDir, ValidPrototypeCatalogJson); File.WriteAllText( Path.Combine(rewardTablesDir, "extra_reward_tables.json"), """ { "schemaVersion": 1, "rewardTables": [ { "id": "prototype_combat_pocket_clear", "displayName": "Duplicate Table", "fixedGrants": [ { "itemId": "scrap_metal_bulk", "quantity": 10 }, { "itemId": "contract_handoff_token", "quantity": 1 } ] } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate reward table id 'prototype_combat_pocket_clear'", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenE5M3AllowlistMismatch() { // Arrange var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), """ { "schemaVersion": 1, "rewardTables": [ { "id": "wrong_reward_table_id", "displayName": "Wrong Table", "fixedGrants": [ { "itemId": "scrap_metal_bulk", "quantity": 10 }, { "itemId": "contract_handoff_token", "quantity": 1 } ] } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype E5M3 expects exactly reward table ids", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenUnknownItemIdInFixedGrants() { // Arrange var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), """ { "schemaVersion": 1, "rewardTables": [ { "id": "prototype_combat_pocket_clear", "displayName": "Prototype Combat Pocket Clear", "fixedGrants": [ { "itemId": "unknown_item_id", "quantity": 1 } ] } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("itemId 'unknown_item_id' is not in item catalogs", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenDuplicateItemIdInFixedGrants() { // Arrange var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), """ { "schemaVersion": 1, "rewardTables": [ { "id": "prototype_combat_pocket_clear", "displayName": "Prototype Combat Pocket Clear", "fixedGrants": [ { "itemId": "scrap_metal_bulk", "quantity": 5 }, { "itemId": "scrap_metal_bulk", "quantity": 5 } ] } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("duplicate itemId 'scrap_metal_bulk'", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenFrozenGrantQuantitiesMismatch() { // Arrange var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); File.WriteAllText( Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), """ { "schemaVersion": 1, "rewardTables": [ { "id": "prototype_combat_pocket_clear", "displayName": "Prototype Combat Pocket Clear", "fixedGrants": [ { "itemId": "scrap_metal_bulk", "quantity": 9 }, { "itemId": "contract_handoff_token", "quantity": 1 } ] } ] } """, Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("prototype_combat_pocket_clear fixedGrants must match", ioe.Message, StringComparison.Ordinal); } [Fact] public void Load_ShouldThrow_WhenSchemaFileMissing() { // Arrange var (_, rewardTablesDir, _, grantRowSchemaPath) = CreateTempContentLayout(); WriteCatalog(rewardTablesDir, ValidPrototypeCatalogJson); var missingSchema = Path.Combine(rewardTablesDir, "missing-reward-table.schema.json"); // Act var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, missingSchema, grantRowSchemaPath)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal); } }