NEO-34: fail-fast skill catalog load at server startup

Register SkillDefinitionCatalog from content/skills with JsonSchema.Net validation
mirroring validate_content.py and the prototype Slice 1 gate. Resolve paths via
Content:SkillsDirectory or discovery from AppContext.BaseDirectory. Eager-load
after WebApplication build so bad catalogs block startup. Add unit and host
tests, pin test factories to repo skills, document config in server/README, add
manual QA checklist, and extend Bruno GET /health smoke tests.
pull/63/head
VinPropane 2026-05-02 19:44:10 -04:00
parent 6344858541
commit 1a9088c77c
17 changed files with 647 additions and 4 deletions

View File

@ -9,3 +9,13 @@ get {
body: none body: none
auth: none auth: none
} }
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,12 @@
# Manual QA — NEO-34 (skill catalog at startup)
## Prerequisites
- Repo clone with `content/skills` and `content/schemas/skill-def.schema.json` present.
## Checks
- [ ] From `server/NeonSprawl.Server`, run `dotnet run` (no extra `Content` env). Startup logs an **Information** line from category `NeonSprawl.Server.Game.Skills.SkillCatalog` with the **absolute** skills directory path and **3** skills (prototype catalog).
- [ ] `GET /health` returns **200** after startup.
- [ ] Introduce a deliberate catalog error (e.g. temporarily duplicate an `id` in `content/skills/prototype_skills.json`). `dotnet run` **fails** before listening; stderr shows **Skill catalog validation failed** and a path/rule hint. Revert the edit after the check.
- [ ] Optional: set `Content__SkillsDirectory` to a temp directory with invalid JSON and confirm the same fail-fast behavior.

View File

@ -32,10 +32,10 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] Integration or host-level test: **valid** fixture directory → `WebApplicationFactory` (or equivalent) starts and catalog is resolvable from DI. - [x] Integration or host-level test: **valid** fixture directory → `WebApplicationFactory` (or equivalent) starts and catalog is resolvable from DI.
- [ ] Integration or host-level test: **invalid** fixture (malformed JSON, schema violation, duplicate `id`, or Slice-1 gate failure) → host startup **fails** with an **actionable** error (message includes file path and rule hint where practical). - [x] Integration or host-level test: **invalid** fixture (malformed JSON, schema violation, duplicate `id`, or Slice-1 gate failure) → host startup **fails** with an **actionable** error (message includes file path and rule hint where practical).
- [ ] At success: log includes **skill count** and **catalog directory** (Information or Debug per choice above). - [x] At success: log includes **skill count** and **catalog directory** (Information or Debug per choice above).
- [ ] No silent acceptance of bad catalog at runtime (matches E2.M1 acceptance). - [x] No silent acceptance of bad catalog at runtime (matches E2.M1 acceptance).
## Technical approach ## Technical approach

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.PositionState; namespace NeonSprawl.Server.Tests.Game.PositionState;
@ -17,6 +18,11 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
"Set ConnectionStrings__NeonSprawl for Postgres integration tests (see server/README.md)."); "Set ConnectionStrings__NeonSprawl for Postgres integration tests (see server/README.md).");
} }
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.ConfigureAppConfiguration((_, config) => builder.ConfigureAppConfiguration((_, config) =>
{ {
config.AddInMemoryCollection(new Dictionary<string, string?> config.AddInMemoryCollection(new Dictionary<string, string?>

View File

@ -0,0 +1,17 @@
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Skills;
internal static class SkillCatalogTestPaths
{
internal static string DiscoverRepoSkillsDirectory() =>
SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
internal static string DiscoverRepoSkillDefSchemaPath() =>
SkillCatalogPathResolution.ResolveSkillDefSchemaPath(
DiscoverRepoSkillsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
}

View File

@ -0,0 +1,199 @@
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Tests;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
public class SkillDefinitionCatalogLoaderTests
{
private const string ValidPrototypeCatalogJson =
"""
{
"schemaVersion": 1,
"skills": [
{
"id": "salvage",
"category": "gather",
"displayName": "Salvage",
"allowedXpSourceKinds": ["activity", "mission_reward"]
},
{
"id": "refine",
"category": "process",
"displayName": "Refine",
"allowedXpSourceKinds": ["activity", "mission_reward", "trainer"]
},
{
"id": "intrusion",
"category": "tech",
"displayName": "Intrusion",
"allowedXpSourceKinds": ["activity", "mission_reward", "book_or_item"]
}
]
}
""";
private static (string Root, string SkillsDir, string SchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-skillcat-");
var skillsDir = Path.Combine(root.FullName, "content", "skills");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(skillsDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "skill-def.schema.json");
File.Copy(SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(), schemaPath, overwrite: true);
return (root.FullName, skillsDir, schemaPath);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(skillsDir, "prototype.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
// Act
var catalog = SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance);
// Assert
Assert.Equal(3, catalog.DistinctSkillCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.ById.ContainsKey("salvage"));
Assert.Equal("gather", catalog.ById["salvage"].Category);
}
[Fact]
public void Load_ShouldThrow_WhenSkillsIsNotArray()
{
// Arrange
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(skillsDir, "bad.json"), """{"skills": "nope"}""", Encoding.UTF8);
// Act
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'skills' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRowViolatesSchema()
{
// Arrange
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
var bad = """
{
"skills": [
{
"id": "salvage",
"category": "gather",
"displayName": "",
"allowedXpSourceKinds": ["activity"]
}
]
}
""";
File.WriteAllText(Path.Combine(skillsDir, "bad.json"), bad, Encoding.UTF8);
// Act
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Skill catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
{
// Arrange
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
var singleSalvage = """
{
"skills": [
{
"id": "salvage",
"category": "gather",
"displayName": "Salvage",
"allowedXpSourceKinds": ["activity"]
}
]
}
""";
File.WriteAllText(Path.Combine(skillsDir, "a.json"), singleSalvage, Encoding.UTF8);
File.WriteAllText(Path.Combine(skillsDir, "b.json"), singleSalvage, Encoding.UTF8);
// Act
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate skill id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("salvage", ioe.Message, StringComparison.Ordinal);
Assert.Contains("a.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("b.json", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice1IdsIncomplete()
{
// Arrange
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
var twoOnly = """
{
"skills": [
{
"id": "salvage",
"category": "gather",
"displayName": "Salvage",
"allowedXpSourceKinds": ["activity"]
},
{
"id": "refine",
"category": "process",
"displayName": "Refine",
"allowedXpSourceKinds": ["activity"]
}
]
}
""";
File.WriteAllText(Path.Combine(skillsDir, "partial.json"), twoOnly, Encoding.UTF8);
// Act
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype Slice 1 expects exactly skill ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
{
// Arrange — InMemoryWebApplicationFactory strips Postgres and pins Content:SkillsDirectory to the repo catalog.
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/health");
var catalog = factory.Services.GetRequiredService<SkillDefinitionCatalog>();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(3, catalog.DistinctSkillCount);
}
[Fact]
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-skills-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir);
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:SkillsDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Skill catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
}

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing; using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using Npgsql; using Npgsql;
namespace NeonSprawl.Server.Tests; namespace NeonSprawl.Server.Tests;
@ -18,6 +19,11 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
for (var i = services.Count - 1; i >= 0; i--) for (var i = services.Count - 1; i >= 0; i--)

View File

@ -0,0 +1,19 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Configuration for loading authoring content from disk (NEO-34).</summary>
public sealed class ContentPathsOptions
{
public const string SectionName = "Content";
/// <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/skills</c> directory.
/// </summary>
public string? SkillsDirectory { get; set; }
/// <summary>
/// Optional override for <c>skill-def.schema.json</c>.
/// When unset, resolved as <c>{parent of skills directory}/schemas/skill-def.schema.json</c> (same layout as the repo).
/// </summary>
public string? SkillDefSchemaPath { get; set; }
}

View File

@ -0,0 +1,47 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>
/// Prototype Slice 1 roster gate (NEO-33), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_SLICE1_SKILL_IDS</c> / <c>_prototype_slice1_gate</c>.
/// </summary>
public static class PrototypeSlice1SkillCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_SKILL_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedSkillIds = FrozenSet.ToFrozenSet(
["salvage", "refine", "intrusion"],
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> skillIdToSourceFile,
IReadOnlyDictionary<string, string> skillIdToCategory)
{
var ids = skillIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedSkillIds))
{
return
"error: prototype Slice 1 expects exactly skill ids " +
$"[{string.Join(", ", ExpectedSkillIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
}
var cats = skillIdToCategory.Values.ToHashSet(StringComparer.Ordinal);
if (!cats.Contains("gather") || !cats.Contains("tech"))
{
return
"error: prototype Slice 1 requires at least one gather and one tech skill; " +
$"categories seen: [{string.Join(", ", cats.Order(StringComparer.Ordinal).Select(c => "'" + c + "'"))}]";
}
if (!cats.Contains("process") && !cats.Contains("make"))
{
return
"error: prototype Slice 1 requires at least one process or make skill; " +
$"categories seen: [{string.Join(", ", cats.Order(StringComparer.Ordinal).Select(c => "'" + c + "'"))}]";
}
return null;
}
}

View File

@ -0,0 +1,60 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Resolves catalog paths for local dev, tests, and container layouts (NEO-34).</summary>
public static class SkillCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/skills</c> directory.</summary>
public static string? TryDiscoverSkillsDirectory(string startDirectory)
{
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "content", "skills");
if (Directory.Exists(candidate))
return Path.GetFullPath(candidate);
}
return null;
}
/// <summary>
/// Resolves the skills catalog directory.
/// Empty <paramref name="configuredSkillsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
public static string ResolveSkillsDirectory(string? configuredSkillsDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredSkillsDirectory))
{
var discovered = TryDiscoverSkillsDirectory(AppContext.BaseDirectory);
if (discovered is not null)
return discovered;
throw new InvalidOperationException(
"Content:SkillsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/skills'). " +
"Set Content:SkillsDirectory in configuration or environment (e.g. Content__SkillsDirectory).");
}
var trimmed = configuredSkillsDirectory.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
/// <summary>Resolves JSON Schema path for a single skill row (Draft 2020-12).</summary>
public static string ResolveSkillDefSchemaPath(
string skillsDirectory,
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(skillsDirectory, "..", "schemas", "skill-def.schema.json"));
}
}

View File

@ -0,0 +1,34 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>DI registration for the fail-fast skill catalog (NEO-34).</summary>
public static class SkillCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="SkillDefinitionCatalog"/> as a singleton.</summary>
public static IServiceCollection AddSkillDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<SkillDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Skills.SkillCatalog");
var skillsDir = SkillCatalogPathResolution.ResolveSkillsDirectory(opts.SkillsDirectory, hostEnv.ContentRootPath);
var schemaPath = SkillCatalogPathResolution.ResolveSkillDefSchemaPath(
skillsDir,
opts.SkillDefSchemaPath,
hostEnv.ContentRootPath);
return SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, logger);
});
return services;
}
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>One validated <c>SkillDef</c> row from <c>content/skills/*.json</c> (NEO-34).</summary>
public sealed record SkillDefRow(
string Id,
string Category,
string DisplayName,
IReadOnlyList<string> AllowedXpSourceKinds);

View File

@ -0,0 +1,27 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>In-memory skill catalog loaded at startup (NEO-34). NEO-35 may wrap with lookup services.</summary>
public sealed class SkillDefinitionCatalog
{
public SkillDefinitionCatalog(
string skillsDirectory,
IReadOnlyDictionary<string, SkillDefRow> byId,
int catalogJsonFileCount)
{
SkillsDirectory = skillsDirectory;
ById = new ReadOnlyDictionary<string, SkillDefRow>(new Dictionary<string, SkillDefRow>(byId, StringComparer.Ordinal));
CatalogJsonFileCount = catalogJsonFileCount;
}
/// <summary>Absolute path to the directory that was enumerated for <c>*.json</c> catalogs.</summary>
public string SkillsDirectory { get; }
public IReadOnlyDictionary<string, SkillDefRow> ById { get; }
public int DistinctSkillCount => ById.Count;
/// <summary>Number of <c>*.json</c> files under <see cref="SkillsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; }
}

View File

@ -0,0 +1,181 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Loads and validates <c>content/skills/*.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-34).</summary>
public static class SkillDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static SkillDefinitionCatalog Load(string skillsDirectory, string schemaPath, ILogger logger)
{
skillsDirectory = Path.GetFullPath(skillsDirectory);
schemaPath = Path.GetFullPath(schemaPath);
var errors = new List<string>();
if (!File.Exists(schemaPath))
errors.Add($"error: missing schema file {schemaPath}");
if (!Directory.Exists(skillsDirectory))
errors.Add($"error: missing directory {skillsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var jsonFiles = Directory.GetFiles(skillsDirectory, "*.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
if (jsonFiles.Length == 0)
errors.Add($"error: no JSON files under {skillsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var skillIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
var skillIdToCategory = new Dictionary<string, string>(StringComparer.Ordinal);
var rows = new Dictionary<string, SkillDefRow>(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;
}
var skillsNode = root?["skills"];
if (skillsNode is not JsonArray skillsArray)
{
errors.Add($"error: {path}: expected top-level 'skills' array");
continue;
}
for (var i = 0; i < skillsArray.Count; i++)
{
var item = skillsArray[i];
if (item is not JsonObject rowObj)
{
errors.Add($"error: {path}: skills[{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} skills[{i}] (root): schema validation failed");
errors.AddRange(schemaMsgs);
}
var rowSchemaErrors = schemaMsgs.Count;
var sid = (rowObj["id"] as JsonValue)?.GetValue<string>();
if (sid is not null && rowSchemaErrors == 0)
{
if (skillIdToSourceFile.TryGetValue(sid, out var prevPath))
{
errors.Add($"error: duplicate skill id '{sid}' in {prevPath} and {path}");
continue;
}
skillIdToSourceFile[sid] = path;
var category = (rowObj["category"] as JsonValue)?.GetValue<string>();
if (category is not null)
skillIdToCategory[sid] = category;
rows[sid] = ParseRow(rowObj);
}
}
}
ThrowIfAny(errors);
var slice1 = PrototypeSlice1SkillCatalogRules.TryGetSlice1GateError(skillIdToSourceFile, skillIdToCategory);
if (slice1 is not null)
{
errors.Add(slice1);
ThrowIfAny(errors);
}
logger.LogInformation(
"Loaded skill catalog from {SkillsDirectory}: {SkillCount} skill(s) across {CatalogFileCount} JSON catalog file(s).",
skillsDirectory,
rows.Count,
jsonFiles.Length);
return new SkillDefinitionCatalog(skillsDirectory, rows, jsonFiles.Length);
}
private static SkillDefRow ParseRow(JsonObject rowObj)
{
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
var category = (rowObj["category"] as JsonValue)!.GetValue<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var kindsArray = rowObj["allowedXpSourceKinds"] as JsonArray
?? throw new InvalidOperationException("allowedXpSourceKinds missing");
var kinds = new List<string>();
foreach (var n in kindsArray)
{
if (n is JsonValue v)
kinds.Add(v.GetValue<string>());
}
return new SkillDefRow(id, category, displayName, kinds);
}
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} skills[{index}] {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Skill catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

@ -19,6 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="JsonSchema.Net" Version="7.4.0" />
<PackageReference Include="Npgsql" Version="10.0.0" /> <PackageReference Include="Npgsql" Version="10.0.0" />
</ItemGroup> </ItemGroup>

View File

@ -1,6 +1,7 @@
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -8,8 +9,10 @@ builder.Services.AddPositionStateStore(builder.Configuration);
builder.Services.AddHotbarLoadoutStore(builder.Configuration); builder.Services.AddHotbarLoadoutStore(builder.Configuration);
builder.Services.AddAbilityCooldownStore(); builder.Services.AddAbilityCooldownStore();
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>(); builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
var app = builder.Build(); var app = builder.Build();
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
app.MapGet("/", () => Results.Text( app.MapGet("/", () => Results.Text(
"Neon Sprawl game server — GET /health for JSON status.", "Neon Sprawl game server — GET /health for JSON status.",

View File

@ -24,6 +24,19 @@ NEON_SPRAWL_API_LOG=1 dotnet run --project server/NeonSprawl.Server/NeonSprawl.S
IDE: use launch profile **`http+apiLog`** (same URL as **`http`**, sets the variable). IDE: use launch profile **`http+apiLog`** (same URL as **`http`**, sets the variable).
## Skill catalog (`content/skills`, NEO-34)
On startup the host loads every **`*.json`** under the skills directory, validates each row against **`content/schemas/skill-def.schema.json`**, rejects **duplicate `id`** values across files, and enforces the **prototype Slice 1** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
| Config | Meaning |
|--------|---------|
| **`Content:SkillsDirectory`** | Optional. Absolute path, or path relative to the server **content root** (the project directory for `dotnet run`). When unset, the host walks **ancestors of `AppContext.BaseDirectory`** until it finds a **`content/skills`** directory (works for `dotnet run` from `server/NeonSprawl.Server` without extra config). |
| **`Content:SkillDefSchemaPath`** | Optional override for **`skill-def.schema.json`**. When unset, resolved as **`{parent of skills directory}/schemas/skill-def.schema.json`** (repo layout: `content/schemas` next to `content/skills`). |
**Docker / CI:** mount or copy the repo **`content/`** tree (at least **`content/skills`** and **`content/schemas`**) and set **`Content__SkillsDirectory`** / **`Content__SkillDefSchemaPath`** if the layout differs from the default discovery rule.
On success, **Information** logs include the resolved skills directory path and distinct skill count.
## Position persistence (NEO-8) ## Position persistence (NEO-8)
When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x``sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory stores constructor seed (not on every HTTP request). When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x``sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory stores constructor seed (not on every HTTP request).