NEO-51: Fail-fast item catalog load at server startup.

Mirror NEO-34/validate_content.py: *_items.json schema validation,
Slice 1 gate, ItemDefinitionCatalog DI, loader/host tests, Bruno health smoke.
pull/86/head
VinPropane 2026-05-17 21:56:09 -04:00
parent b461f3bf47
commit 2ba129dca1
14 changed files with 773 additions and 1 deletions

View File

@ -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");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: item-catalog
}

View File

@ -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);
}

View File

@ -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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<ItemDefinitionCatalog>();
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<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:ItemsDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Item catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
}

View File

@ -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<Progra
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services =>

View File

@ -0,0 +1,60 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Resolves item catalog paths for local dev, tests, and container layouts (NEO-51).</summary>
public static class ItemCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/items</c> directory.</summary>
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;
}
/// <summary>
/// Resolves the items catalog directory.
/// Empty <paramref name="configuredItemsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
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));
}
/// <summary>Resolves JSON Schema path for a single item row (Draft 2020-12).</summary>
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"));
}
}

View File

@ -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;
/// <summary>DI registration for the fail-fast item catalog (NEO-51).</summary>
public static class ItemCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ItemDefinitionCatalog"/> as a singleton.</summary>
public static IServiceCollection AddItemDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<ItemDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>()
.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;
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>One validated <c>ItemDef</c> row from <c>content/items/*_items.json</c> (NEO-51).</summary>
public sealed record ItemDefRow(
string Id,
string DisplayName,
string PrototypeRole,
int StackMax,
string InventorySlotKind,
string? Rarity,
string? BindPolicy,
int? DurabilityMax);

View File

@ -0,0 +1,31 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Items;
/// <summary>In-memory item catalog loaded at startup (NEO-51). Game code should prefer injectable item registry for lookups (NEO-52).</summary>
public sealed class ItemDefinitionCatalog
{
public ItemDefinitionCatalog(
string itemsDirectory,
IReadOnlyDictionary<string, ItemDefRow> byId,
int catalogJsonFileCount)
{
ItemsDirectory = itemsDirectory;
ById = new ReadOnlyDictionary<string, ItemDefRow>(new Dictionary<string, ItemDefRow>(byId, StringComparer.Ordinal));
CatalogJsonFileCount = catalogJsonFileCount;
}
/// <summary>Absolute path to the directory that was enumerated for <c>*_items.json</c> catalogs.</summary>
public string ItemsDirectory { get; }
public IReadOnlyDictionary<string, ItemDefRow> ById { get; }
public int DistinctItemCount => ById.Count;
/// <summary>Number of <c>*_items.json</c> files under <see cref="ItemsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; }
/// <summary>Resolves a catalog row by stable <paramref name="id"/>.</summary>
public bool TryGetItem(string id, out ItemDefRow? row) =>
ById.TryGetValue(id, out row);
}

View File

@ -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;
/// <summary>Loads and validates <c>content/items/*_items.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-51).</summary>
public static class ItemDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static ItemDefinitionCatalog Load(string itemsDirectory, string schemaPath, ILogger logger)
{
itemsDirectory = Path.GetFullPath(itemsDirectory);
schemaPath = Path.GetFullPath(schemaPath);
var errors = new List<string>();
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<string, string>(StringComparer.Ordinal);
var idToRole = new Dictionary<string, string>(StringComparer.Ordinal);
var idToSlotKind = new Dictionary<string, string>(StringComparer.Ordinal);
var idToStackMax = new Dictionary<string, int>(StringComparer.Ordinal);
var rows = new Dictionary<string, ItemDefRow>(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<int>(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<string>();
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<string>();
if (role is not null)
idToRole[iid] = role;
var slotKind = (rowObj["inventorySlotKind"] as JsonValue)?.GetValue<string>();
if (slotKind is not null)
idToSlotKind[iid] = slotKind;
if (rowObj["stackMax"] is JsonValue stackMaxValue &&
stackMaxValue.TryGetValue<int>(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<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var prototypeRole = (rowObj["prototypeRole"] as JsonValue)!.GetValue<string>();
var stackMax = (rowObj["stackMax"] as JsonValue)!.GetValue<int>();
var inventorySlotKind = (rowObj["inventorySlotKind"] as JsonValue)!.GetValue<string>();
string? rarity = null;
if (rowObj["rarity"] is JsonValue rarityValue)
rarity = rarityValue.GetValue<string>();
string? bindPolicy = null;
if (rowObj["bindPolicy"] is JsonValue bindPolicyValue)
bindPolicy = bindPolicyValue.GetValue<string>();
int? durabilityMax = null;
if (rowObj["durabilityMax"] is JsonValue durabilityMaxValue &&
durabilityMaxValue.TryGetValue<int>(out var dmax))
durabilityMax = dmax;
return new ItemDefRow(id, displayName, prototypeRole, stackMax, inventorySlotKind, rarity, bindPolicy, durabilityMax);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> 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<string> 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());
}
}

View File

@ -0,0 +1,81 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Items;
/// <summary>
/// Prototype Slice 1 roster gate (NEO-50), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_SLICE1_ITEM_IDS</c> / <c>_prototype_slice1_item_gate</c>.
/// </summary>
public static class PrototypeSlice1ItemCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_ITEM_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedItemIds = FrozenSet.ToFrozenSet(
[
"scrap_metal_bulk",
"refined_plate_stock",
"field_stim_mk0",
"survey_drone_kit",
"contract_handoff_token",
"prototype_armor_shell",
],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_ITEM_ROLES</c>.</summary>
public static readonly FrozenSet<string> ExpectedPrototypeRoles = FrozenSet.ToFrozenSet(
["material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"],
StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the Slice 1 contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetSlice1GateError(
IReadOnlyDictionary<string, string> itemIdToSourceFile,
IReadOnlyDictionary<string, string> idToRole,
IReadOnlyDictionary<string, string> idToSlotKind,
IReadOnlyDictionary<string, int> 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;
}
}

View File

@ -28,4 +28,16 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of mastery directory}/schemas/mastery-catalog.schema.json</c>.
/// </summary>
public string? MasteryCatalogSchemaPath { get; set; }
/// <summary>
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/items</c> directory.
/// </summary>
public string? ItemsDirectory { get; set; }
/// <summary>
/// Optional override for <c>item-def.schema.json</c>.
/// When unset, resolved as <c>{parent of items directory}/schemas/item-def.schema.json</c>.
/// </summary>
public string? ItemDefSchemaPath { get; set; }
}

View File

@ -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<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build();
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
_ = app.Services.GetRequiredService<MasteryCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>();

View File

@ -8,7 +8,9 @@
"AllowedHosts": "*",
"Content": {
"SkillsDirectory": "",
"SkillDefSchemaPath": ""
"SkillDefSchemaPath": "",
"ItemsDirectory": "",
"ItemDefSchemaPath": ""
},
"Game": {
"DevPlayerId": "dev-local-1",