diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeDefinitionRegistryTests.cs new file mode 100644 index 0000000..b334627 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeDefinitionRegistryTests.cs @@ -0,0 +1,315 @@ +using System.IO; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Gathering; + +public class ResourceNodeDefinitionRegistryTests +{ + private static ResourceNodeDefinitionRegistry CreateRegistryFromRows( + IReadOnlyDictionary nodesById, + IReadOnlyDictionary yieldsByNodeDefId) + { + var catalog = new ResourceNodeCatalog( + "/tmp/resource-nodes", + nodesById, + yieldsByNodeDefId, + nodeCatalogJsonFileCount: 1, + yieldCatalogJsonFileCount: 1); + return new ResourceNodeDefinitionRegistry(catalog); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenNodeDefIdExists() + { + // Arrange + const string nodeDefId = "prototype_resource_node_alpha"; + var nodes = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceNodeDefRow( + nodeDefId, + "Prototype Salvage Heap", + "consumer_salvage", + MaxGathers: 10), + }; + var yields = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceYieldRow(nodeDefId, "scrap_metal_bulk", Quantity: 1), + }; + var registry = CreateRegistryFromRows(nodes, yields); + // Act + var found = registry.TryGetDefinition(nodeDefId, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal("Prototype Salvage Heap", def.DisplayName); + Assert.Equal("consumer_salvage", def.GatherLens); + Assert.Equal(10, def.MaxGathers); + } + + [Fact] + public void TryGetYield_ShouldReturnTrueAndExpectedMetadata_WhenNodeDefIdExists() + { + // Arrange + const string nodeDefId = "prototype_resource_node_alpha"; + var nodes = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceNodeDefRow( + nodeDefId, + "Prototype Salvage Heap", + "consumer_salvage", + MaxGathers: 10), + }; + var yields = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceYieldRow(nodeDefId, "scrap_metal_bulk", Quantity: 1), + }; + var registry = CreateRegistryFromRows(nodes, yields); + // Act + var found = registry.TryGetYield(nodeDefId, out var yield); + // Assert + Assert.True(found); + Assert.NotNull(yield); + Assert.Equal("scrap_metal_bulk", yield.ItemId); + Assert.Equal(1, yield.Quantity); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenNodeDefIdIsNull() + { + // Arrange + const string nodeDefId = "prototype_resource_node_alpha"; + var nodes = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceNodeDefRow( + nodeDefId, + "Prototype Salvage Heap", + "consumer_salvage", + MaxGathers: 10), + }; + var yields = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceYieldRow(nodeDefId, "scrap_metal_bulk", Quantity: 1), + }; + var registry = CreateRegistryFromRows(nodes, yields); + // Act + var found = registry.TryGetDefinition(null, out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetYield_ShouldReturnFalse_WhenNodeDefIdIsNull() + { + // Arrange + const string nodeDefId = "prototype_resource_node_alpha"; + var nodes = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceNodeDefRow( + nodeDefId, + "Prototype Salvage Heap", + "consumer_salvage", + MaxGathers: 10), + }; + var yields = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceYieldRow(nodeDefId, "scrap_metal_bulk", Quantity: 1), + }; + var registry = CreateRegistryFromRows(nodes, yields); + // Act + var found = registry.TryGetYield(null, out var yield); + // Assert + Assert.False(found); + Assert.Null(yield); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenNodeDefIdUnknown() + { + // Arrange + const string nodeDefId = "prototype_resource_node_alpha"; + var nodes = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceNodeDefRow( + nodeDefId, + "Prototype Salvage Heap", + "consumer_salvage", + MaxGathers: 10), + }; + var yields = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceYieldRow(nodeDefId, "scrap_metal_bulk", Quantity: 1), + }; + var registry = CreateRegistryFromRows(nodes, yields); + // Act + var found = registry.TryGetDefinition("not_a_real_node", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetYield_ShouldReturnFalse_WhenNodeDefIdUnknown() + { + // Arrange + const string nodeDefId = "prototype_resource_node_alpha"; + var nodes = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceNodeDefRow( + nodeDefId, + "Prototype Salvage Heap", + "consumer_salvage", + MaxGathers: 10), + }; + var yields = new Dictionary(StringComparer.Ordinal) + { + [nodeDefId] = new ResourceYieldRow(nodeDefId, "scrap_metal_bulk", Quantity: 1), + }; + var registry = CreateRegistryFromRows(nodes, yields); + // Act + var found = registry.TryGetYield("not_a_real_node", out var yield); + // Assert + Assert.False(found); + Assert.Null(yield); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedByNodeDefId_WhenMultipleNodes() + { + // Arrange + var nodes = new Dictionary(StringComparer.Ordinal) + { + ["prototype_urban_bulk_delta"] = new ResourceNodeDefRow( + "prototype_urban_bulk_delta", + "Prototype Urban Bulk", + "urban_bulk", + MaxGathers: 10), + ["prototype_bio_mat_gamma"] = new ResourceNodeDefRow( + "prototype_bio_mat_gamma", + "Prototype Bio Mat", + "bio", + MaxGathers: 10), + ["prototype_resource_node_alpha"] = new ResourceNodeDefRow( + "prototype_resource_node_alpha", + "Prototype Salvage Heap", + "consumer_salvage", + MaxGathers: 10), + ["prototype_subsurface_vein_beta"] = new ResourceNodeDefRow( + "prototype_subsurface_vein_beta", + "Prototype Subsurface Vein", + "subsurface", + MaxGathers: 10), + }; + var yields = new Dictionary(StringComparer.Ordinal) + { + ["prototype_urban_bulk_delta"] = new ResourceYieldRow( + "prototype_urban_bulk_delta", + "scrap_metal_bulk", + Quantity: 5), + ["prototype_bio_mat_gamma"] = new ResourceYieldRow( + "prototype_bio_mat_gamma", + "scrap_metal_bulk", + Quantity: 3), + ["prototype_resource_node_alpha"] = new ResourceYieldRow( + "prototype_resource_node_alpha", + "scrap_metal_bulk", + Quantity: 1), + ["prototype_subsurface_vein_beta"] = new ResourceYieldRow( + "prototype_subsurface_vein_beta", + "scrap_metal_bulk", + Quantity: 2), + }; + var registry = CreateRegistryFromRows(nodes, yields); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(4, list.Count); + Assert.Equal("prototype_bio_mat_gamma", list[0].NodeDefId); + Assert.Equal("prototype_resource_node_alpha", list[1].NodeDefId); + Assert.Equal("prototype_subsurface_vein_beta", list[2].NodeDefId); + Assert.Equal("prototype_urban_bulk_delta", list[3].NodeDefId); + } + + [Fact] + public void TryGetDefinition_ShouldMatchLoaderCatalog_WhenUsingPrototypeFixture() + { + // Arrange + var root = Directory.CreateTempSubdirectory("neon-sprawl-resource-node-registry-loader-"); + try + { + var resourceNodesDir = Path.Combine(root.FullName, "content", "resource-nodes"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(resourceNodesDir); + Directory.CreateDirectory(schemaDir); + var nodeSchemaPath = Path.Combine(schemaDir, "resource-node-def.schema.json"); + var yieldSchemaPath = Path.Combine(schemaDir, "resource-yield-row.schema.json"); + File.Copy(ResourceNodeCatalogTestPaths.DiscoverRepoResourceNodeDefSchemaPath(), nodeSchemaPath, overwrite: true); + File.Copy(ResourceNodeCatalogTestPaths.DiscoverRepoResourceYieldRowSchemaPath(), yieldSchemaPath, overwrite: true); + File.Copy( + Path.Combine(ResourceNodeCatalogTestPaths.DiscoverRepoResourceNodesDirectory(), "prototype_resource_nodes.json"), + Path.Combine(resourceNodesDir, "prototype_resource_nodes.json"), + overwrite: true); + File.Copy( + Path.Combine(ResourceNodeCatalogTestPaths.DiscoverRepoResourceNodesDirectory(), "prototype_resource_yields.json"), + Path.Combine(resourceNodesDir, "prototype_resource_yields.json"), + overwrite: true); + var itemIds = new HashSet(StringComparer.Ordinal) { "scrap_metal_bulk" }; + var loaded = ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + itemIds, + NullLogger.Instance); + var registry = new ResourceNodeDefinitionRegistry(loaded); + // Act + var ok = registry.TryGetDefinition("prototype_resource_node_alpha", out var node); + var yieldOk = registry.TryGetYield("prototype_resource_node_alpha", out var yield); + // Assert + Assert.True(ok); + Assert.NotNull(node); + Assert.Equal("consumer_salvage", node.GatherLens); + Assert.Equal(10, node.MaxGathers); + Assert.True(yieldOk); + Assert.NotNull(yield); + Assert.Equal("scrap_metal_bulk", yield.ItemId); + Assert.Equal(1, yield.Quantity); + } + finally + { + try + { + Directory.Delete(root.FullName, recursive: true); + } + catch (IOException) + { + // Best-effort: transient lock or race on some hosts; temp dir is unique per run. + } + } + } + + [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_resource_node_alpha", out var node); + var yieldFound = registry.TryGetYield("prototype_resource_node_alpha", out var yield); + // Assert + Assert.True(found); + Assert.NotNull(node); + Assert.Equal("consumer_salvage", node.GatherLens); + Assert.Equal(10, node.MaxGathers); + Assert.True(yieldFound); + Assert.NotNull(yield); + Assert.Equal("scrap_metal_bulk", yield.ItemId); + Assert.Equal(1, yield.Quantity); + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/IResourceNodeDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Gathering/IResourceNodeDefinitionRegistry.cs new file mode 100644 index 0000000..671b90e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/IResourceNodeDefinitionRegistry.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Gathering; + +/// +/// Read-only access to validated resource-node definitions and yield rows loaded at startup +/// (). +/// +/// +/// E3.M1 (gather / depletion / interact): callers resolving node defs or yields should depend on this +/// interface rather than so gather lens, capacity, and yield metadata stay +/// centralized. +/// NEO-60: HTTP/read-model projections should depend on this interface rather than reaching into the +/// catalog. +/// +public interface IResourceNodeDefinitionRegistry +{ + /// + /// Attempts to resolve a node def by stable nodeDefId (see resource-node-def.schema.json). + /// Unknown ids and null return false without throwing. + /// + bool TryGetDefinition(string? nodeDefId, [NotNullWhen(true)] out ResourceNodeDefRow? definition); + + /// + /// Attempts to resolve the fixed yield row for (see + /// resource-yield-row.schema.json). Unknown ids and null return false without throwing. + /// + bool TryGetYield(string? nodeDefId, [NotNullWhen(true)] out ResourceYieldRow? yield); + + /// Every loaded node definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs index 40558ad..9d1b6c4 100644 --- a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs @@ -2,7 +2,7 @@ using System.Collections.ObjectModel; namespace NeonSprawl.Server.Game.Gathering; -/// In-memory resource-node catalog loaded at startup (NEO-58). Prefer registry in NEO-59 for game lookups. +/// In-memory resource-node catalog loaded at startup (NEO-58). Game callers should use . public sealed class ResourceNodeCatalog( string resourceNodesDirectory, IReadOnlyDictionary nodesById, diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs index 5c5f47b..2fea0b1 100644 --- a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs @@ -6,10 +6,13 @@ using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Gathering; -/// DI registration for the fail-fast resource-node catalog (NEO-58). +/// DI registration for the fail-fast resource-node catalog and registry (NEO-58, NEO-59). public static class ResourceNodeCatalogServiceCollectionExtensions { - /// Binds and registers as a singleton. + /// + /// Binds and registers and + /// as singletons. + /// public static IServiceCollection AddResourceNodeCatalog(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -45,6 +48,9 @@ public static class ResourceNodeCatalogServiceCollectionExtensions logger); }); + services.AddSingleton(sp => + new ResourceNodeDefinitionRegistry(sp.GetRequiredService())); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefinitionRegistry.cs new file mode 100644 index 0000000..07aab07 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefinitionRegistry.cs @@ -0,0 +1,58 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Gathering; + +/// Adapter over (NEO-59). +public sealed class ResourceNodeDefinitionRegistry(ResourceNodeCatalog catalog) : IResourceNodeDefinitionRegistry +{ + /// + public bool TryGetDefinition(string? nodeDefId, [NotNullWhen(true)] out ResourceNodeDefRow? definition) + { + if (nodeDefId is null) + { + definition = null; + return false; + } + + if (catalog.NodesById.TryGetValue(nodeDefId, out var row)) + { + definition = row; + return true; + } + + definition = null; + return false; + } + + /// + public bool TryGetYield(string? nodeDefId, [NotNullWhen(true)] out ResourceYieldRow? yield) + { + if (nodeDefId is null) + { + yield = null; + return false; + } + + if (catalog.YieldsByNodeDefId.TryGetValue(nodeDefId, out var row)) + { + yield = row; + return true; + } + + yield = null; + return false; + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() + { + var ids = catalog.NodesById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.NodesById[id]); + } + + return list; + } +}