diff --git a/bruno/neon-sprawl-server/item-catalog/Health after item catalog load.bru b/bruno/neon-sprawl-server/item-catalog/Health after item catalog load.bru new file mode 100644 index 0000000..7985da6 --- /dev/null +++ b/bruno/neon-sprawl-server/item-catalog/Health after item catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (item catalog boot NEO-51) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-51 loads content/items/*_items.json at startup (fail-fast). No item 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/item-catalog/folder.bru b/bruno/neon-sprawl-server/item-catalog/folder.bru new file mode 100644 index 0000000..d950aa9 --- /dev/null +++ b/bruno/neon-sprawl-server/item-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: item-catalog +} diff --git a/server/NeonSprawl.Server.Tests/Game/Items/ItemCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Items/ItemCatalogTestPaths.cs new file mode 100644 index 0000000..664b27c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/ItemCatalogTestPaths.cs @@ -0,0 +1,17 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Tests.Game.Items; + +internal static class ItemCatalogTestPaths +{ + internal static string DiscoverRepoItemsDirectory() => + ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoItemDefSchemaPath() => + ItemCatalogPathResolution.ResolveItemDefSchemaPath( + DiscoverRepoItemsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..96c2137 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs @@ -0,0 +1,271 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Items; + +public class ItemDefinitionCatalogLoaderTests +{ + private const string ValidPrototypeCatalogJson = + """ + { + "schemaVersion": 1, + "items": [ + { + "id": "scrap_metal_bulk", + "displayName": "Scrap Metal (Bulk)", + "prototypeRole": "material", + "stackMax": 999, + "inventorySlotKind": "bag" + }, + { + "id": "refined_plate_stock", + "displayName": "Refined Plate Stock", + "prototypeRole": "intermediate", + "stackMax": 999, + "inventorySlotKind": "bag" + }, + { + "id": "field_stim_mk0", + "displayName": "Field Stim Mk0", + "prototypeRole": "consumable", + "stackMax": 20, + "inventorySlotKind": "bag" + }, + { + "id": "survey_drone_kit", + "displayName": "Survey Drone Kit", + "prototypeRole": "utility", + "stackMax": 1, + "inventorySlotKind": "bag" + }, + { + "id": "contract_handoff_token", + "displayName": "Contract Handoff Token", + "prototypeRole": "quest_token", + "stackMax": 1, + "inventorySlotKind": "bag" + }, + { + "id": "prototype_armor_shell", + "displayName": "Prototype Armor Shell", + "prototypeRole": "equip_stub", + "stackMax": 1, + "inventorySlotKind": "equipment" + } + ] + } + """; + + private static (string Root, string ItemsDir, string SchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-itemcat-"); + var itemsDir = Path.Combine(root.FullName, "content", "items"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(itemsDir); + Directory.CreateDirectory(schemaDir); + var schemaPath = Path.Combine(schemaDir, "item-def.schema.json"); + File.Copy(ItemCatalogTestPaths.DiscoverRepoItemDefSchemaPath(), schemaPath, overwrite: true); + return (root.FullName, itemsDir, schemaPath); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, itemsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), ValidPrototypeCatalogJson, Encoding.UTF8); + // Act + var catalog = ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance); + // Assert + Assert.Equal(6, catalog.DistinctItemCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.TryGetItem("scrap_metal_bulk", out var row)); + Assert.NotNull(row); + Assert.Equal("material", row!.PrototypeRole); + Assert.Equal(999, row.StackMax); + } + + [Fact] + public void Load_ShouldThrow_WhenItemsIsNotArray() + { + // Arrange + var (_, itemsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(itemsDir, "bad_items.json"), + """{"schemaVersion": 1, "items": "nope"}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'items' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() + { + // Arrange + var (_, itemsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(itemsDir, "bad_items.json"), + """{"schemaVersion": 2, "items": []}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenRowViolatesSchema() + { + // Arrange + var (_, itemsDir, schemaPath) = CreateTempContentLayout(); + var bad = """ + { + "schemaVersion": 1, + "items": [ + { + "id": "scrap_metal_bulk", + "displayName": "", + "prototypeRole": "material", + "stackMax": 999, + "inventorySlotKind": "bag" + } + ] + } + """; + File.WriteAllText(Path.Combine(itemsDir, "bad_items.json"), bad, Encoding.UTF8); + // Act + var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("Item catalog validation failed", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles() + { + // Arrange + var (_, itemsDir, schemaPath) = CreateTempContentLayout(); + var singleItem = """ + { + "schemaVersion": 1, + "items": [ + { + "id": "scrap_metal_bulk", + "displayName": "Scrap Metal (Bulk)", + "prototypeRole": "material", + "stackMax": 999, + "inventorySlotKind": "bag" + } + ] + } + """; + File.WriteAllText(Path.Combine(itemsDir, "a_items.json"), singleItem, Encoding.UTF8); + File.WriteAllText(Path.Combine(itemsDir, "b_items.json"), singleItem, Encoding.UTF8); + // Act + var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate item id", ioe.Message, StringComparison.Ordinal); + Assert.Contains("scrap_metal_bulk", ioe.Message, StringComparison.Ordinal); + Assert.Contains("a_items.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("b_items.json", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSlice1IdsIncomplete() + { + // Arrange + var (_, itemsDir, schemaPath) = CreateTempContentLayout(); + var twoOnly = """ + { + "schemaVersion": 1, + "items": [ + { + "id": "scrap_metal_bulk", + "displayName": "Scrap Metal (Bulk)", + "prototypeRole": "material", + "stackMax": 999, + "inventorySlotKind": "bag" + }, + { + "id": "refined_plate_stock", + "displayName": "Refined Plate Stock", + "prototypeRole": "intermediate", + "stackMax": 999, + "inventorySlotKind": "bag" + } + ] + } + """; + File.WriteAllText(Path.Combine(itemsDir, "partial_items.json"), twoOnly, Encoding.UTF8); + // Act + var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype Slice 1 expects exactly item ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenEquipStubUsesBagSlot() + { + // Arrange + var (_, itemsDir, schemaPath) = CreateTempContentLayout(); + var badEquip = ValidPrototypeCatalogJson.Replace( + "\"inventorySlotKind\": \"equipment\"", + "\"inventorySlotKind\": \"bag\"", + StringComparison.Ordinal); + File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), badEquip, Encoding.UTF8); + // Act + var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("equip_stub", ioe.Message, StringComparison.Ordinal); + Assert.Contains("inventorySlotKind 'equipment'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Host_ShouldResolveCatalogFromDi_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 catalog = factory.Services.GetRequiredService(); + Assert.Equal(6, catalog.DistinctItemCount); + Assert.True(catalog.TryGetItem("prototype_armor_shell", out var shell)); + Assert.Equal("equipment", shell!.InventorySlotKind); + } + + [Fact] + public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-items-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:ItemsDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("Item catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 4eb9aa1..d7ad919 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.PositionState; +using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -30,8 +31,12 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory diff --git a/server/NeonSprawl.Server/Game/Items/ItemCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Items/ItemCatalogPathResolution.cs new file mode 100644 index 0000000..24561f4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemCatalogPathResolution.cs @@ -0,0 +1,60 @@ +namespace NeonSprawl.Server.Game.Items; + +/// Resolves item catalog paths for local dev, tests, and container layouts (NEO-51). +public static class ItemCatalogPathResolution +{ + /// Walks and parents for an existing content/items directory. + public static string? TryDiscoverItemsDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "items"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the items catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveItemsDirectory(string? configuredItemsDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredItemsDirectory)) + { + var discovered = TryDiscoverItemsDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:ItemsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/items'). " + + "Set Content:ItemsDirectory in configuration or environment (e.g. Content__ItemsDirectory)."); + } + + var trimmed = configuredItemsDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single item row (Draft 2020-12). + public static string ResolveItemDefSchemaPath( + string itemsDirectory, + 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(itemsDirectory, "..", "schemas", "item-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..3a0c731 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Items; + +/// DI registration for the fail-fast item catalog (NEO-51). +public static class ItemCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddItemDefinitionCatalog(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 logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Items.ItemCatalog"); + + var itemsDir = ItemCatalogPathResolution.ResolveItemsDirectory(opts.ItemsDirectory, hostEnv.ContentRootPath); + var schemaPath = ItemCatalogPathResolution.ResolveItemDefSchemaPath( + itemsDir, + opts.ItemDefSchemaPath, + hostEnv.ContentRootPath); + + return ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Items/ItemDefRow.cs b/server/NeonSprawl.Server/Game/Items/ItemDefRow.cs new file mode 100644 index 0000000..3e73abc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemDefRow.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Items; + +/// One validated ItemDef row from content/items/*_items.json (NEO-51). +public sealed record ItemDefRow( + string Id, + string DisplayName, + string PrototypeRole, + int StackMax, + string InventorySlotKind, + string? Rarity, + string? BindPolicy, + int? DurabilityMax); diff --git a/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs new file mode 100644 index 0000000..12ac71d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Items; + +/// In-memory item catalog loaded at startup (NEO-51). Game code should prefer injectable item registry for lookups (NEO-52). +public sealed class ItemDefinitionCatalog +{ + public ItemDefinitionCatalog( + string itemsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) + { + ItemsDirectory = itemsDirectory; + ById = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + CatalogJsonFileCount = catalogJsonFileCount; + } + + /// Absolute path to the directory that was enumerated for *_items.json catalogs. + public string ItemsDirectory { get; } + + public IReadOnlyDictionary ById { get; } + + public int DistinctItemCount => ById.Count; + + /// Number of *_items.json files under . + public int CatalogJsonFileCount { get; } + + /// Resolves a catalog row by stable . + public bool TryGetItem(string id, out ItemDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalogLoader.cs new file mode 100644 index 0000000..4f3cf3f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalogLoader.cs @@ -0,0 +1,215 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Items; + +/// Loads and validates content/items/*_items.json using the same rules as scripts/validate_content.py (NEO-51). +public static class ItemDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static ItemDefinitionCatalog Load(string itemsDirectory, string schemaPath, ILogger logger) + { + itemsDirectory = Path.GetFullPath(itemsDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + if (!File.Exists(schemaPath)) + errors.Add($"error: missing schema file {schemaPath}"); + + if (!Directory.Exists(itemsDirectory)) + errors.Add($"error: missing directory {itemsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(itemsDirectory, "*_items.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_items.json files under {itemsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var schema = JsonSchema.FromText(File.ReadAllText(schemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var itemIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var idToRole = new Dictionary(StringComparer.Ordinal); + var idToSlotKind = new Dictionary(StringComparer.Ordinal); + var idToStackMax = 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) == false || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var itemsNode = rootObj["items"]; + if (itemsNode is not JsonArray itemsArray) + { + errors.Add($"error: {path}: expected top-level 'items' array"); + continue; + } + + for (var i = 0; i < itemsArray.Count; i++) + { + var item = itemsArray[i]; + if (item is not JsonObject rowObj) + { + errors.Add($"error: {path}: items[{i}] must be an object"); + continue; + } + + var eval = schema.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} items[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var iid = (rowObj["id"] as JsonValue)?.GetValue(); + if (iid is not null && rowSchemaErrors == 0) + { + if (itemIdToSourceFile.TryGetValue(iid, out var prevPath)) + { + errors.Add($"error: duplicate item id '{iid}' in {prevPath} and {path}"); + continue; + } + + itemIdToSourceFile[iid] = path; + + var role = (rowObj["prototypeRole"] as JsonValue)?.GetValue(); + if (role is not null) + idToRole[iid] = role; + + var slotKind = (rowObj["inventorySlotKind"] as JsonValue)?.GetValue(); + if (slotKind is not null) + idToSlotKind[iid] = slotKind; + + if (rowObj["stackMax"] is JsonValue stackMaxValue && + stackMaxValue.TryGetValue(out var stackMax)) + idToStackMax[iid] = stackMax; + + rows[iid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var slice1 = PrototypeSlice1ItemCatalogRules.TryGetSlice1GateError( + itemIdToSourceFile, + idToRole, + idToSlotKind, + idToStackMax); + if (slice1 is not null) + { + errors.Add(slice1); + ThrowIfAny(errors); + } + + logger.LogInformation( + "Loaded item catalog from {ItemsDirectory}: {ItemCount} item(s) across {CatalogFileCount} JSON catalog file(s).", + itemsDirectory, + rows.Count, + jsonFiles.Length); + + return new ItemDefinitionCatalog(itemsDirectory, rows, jsonFiles.Length); + } + + private static ItemDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var prototypeRole = (rowObj["prototypeRole"] as JsonValue)!.GetValue(); + var stackMax = (rowObj["stackMax"] as JsonValue)!.GetValue(); + var inventorySlotKind = (rowObj["inventorySlotKind"] as JsonValue)!.GetValue(); + string? rarity = null; + if (rowObj["rarity"] is JsonValue rarityValue) + rarity = rarityValue.GetValue(); + string? bindPolicy = null; + if (rowObj["bindPolicy"] is JsonValue bindPolicyValue) + bindPolicy = bindPolicyValue.GetValue(); + int? durabilityMax = null; + if (rowObj["durabilityMax"] is JsonValue durabilityMaxValue && + durabilityMaxValue.TryGetValue(out var dmax)) + durabilityMax = dmax; + + return new ItemDefRow(id, displayName, prototypeRole, stackMax, inventorySlotKind, rarity, bindPolicy, durabilityMax); + } + + 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} items[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Item 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/Items/PrototypeSlice1ItemCatalogRules.cs b/server/NeonSprawl.Server/Game/Items/PrototypeSlice1ItemCatalogRules.cs new file mode 100644 index 0000000..3a717b1 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PrototypeSlice1ItemCatalogRules.cs @@ -0,0 +1,81 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Items; + +/// +/// Prototype Slice 1 roster gate (NEO-50), mirrored from scripts/validate_content.py +/// PROTOTYPE_SLICE1_ITEM_IDS / _prototype_slice1_item_gate. +/// +public static class PrototypeSlice1ItemCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE1_ITEM_IDS. + public static readonly FrozenSet ExpectedItemIds = FrozenSet.ToFrozenSet( + [ + "scrap_metal_bulk", + "refined_plate_stock", + "field_stim_mk0", + "survey_drone_kit", + "contract_handoff_token", + "prototype_armor_shell", + ], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE1_ITEM_ROLES. + public static readonly FrozenSet ExpectedPrototypeRoles = FrozenSet.ToFrozenSet( + ["material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"], + StringComparer.Ordinal); + + /// Returns a human-readable error if the Slice 1 contract fails, otherwise . + public static string? TryGetSlice1GateError( + IReadOnlyDictionary itemIdToSourceFile, + IReadOnlyDictionary idToRole, + IReadOnlyDictionary idToSlotKind, + IReadOnlyDictionary idToStackMax) + { + var ids = itemIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedItemIds)) + { + return + "error: prototype Slice 1 expects exactly item ids " + + $"[{string.Join(", ", ExpectedItemIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + var roles = idToRole.Values.ToHashSet(StringComparer.Ordinal); + if (!roles.SetEquals(ExpectedPrototypeRoles)) + { + return + "error: prototype Slice 1 requires exactly one row per prototypeRole " + + $"[{string.Join(", ", ExpectedPrototypeRoles.Order(StringComparer.Ordinal).Select(r => "'" + r + "'"))}], " + + $"roles seen: [{string.Join(", ", roles.Order(StringComparer.Ordinal).Select(r => "'" + r + "'"))}]"; + } + + string? equipStubId = null; + foreach (var kv in idToRole) + { + if (kv.Value == "equip_stub") + { + equipStubId = kv.Key; + break; + } + } + + if (equipStubId is not null) + { + if (!idToSlotKind.TryGetValue(equipStubId, out var slotKind) || slotKind != "equipment") + { + idToSlotKind.TryGetValue(equipStubId, out slotKind); + return + $"error: '{equipStubId}' (equip_stub) must use inventorySlotKind 'equipment', got '{slotKind}'"; + } + + if (!idToStackMax.TryGetValue(equipStubId, out var stackMax) || stackMax != 1) + { + idToStackMax.TryGetValue(equipStubId, out stackMax); + return $"error: '{equipStubId}' (equip_stub) must use stackMax 1, got {stackMax}"; + } + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index f9713e4..7debb9e 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -28,4 +28,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of mastery directory}/schemas/mastery-catalog.schema.json. /// public string? MasteryCatalogSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/items directory. + /// + public string? ItemsDirectory { get; set; } + + /// + /// Optional override for item-def.schema.json. + /// When unset, resolved as {parent of items directory}/schemas/item-def.schema.json. + /// + public string? ItemDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index a7aaf11..164db2e 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.Interaction; +using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; @@ -13,10 +14,12 @@ builder.Services.AddPerkStateStore(builder.Configuration); builder.Services.AddAbilityCooldownStore(); builder.Services.AddSingleton(); builder.Services.AddSkillDefinitionCatalog(builder.Configuration); +builder.Services.AddItemDefinitionCatalog(builder.Configuration); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index be0b44f..fe7bede 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -8,7 +8,9 @@ "AllowedHosts": "*", "Content": { "SkillsDirectory": "", - "SkillDefSchemaPath": "" + "SkillDefSchemaPath": "", + "ItemsDirectory": "", + "ItemDefSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1",