NEO-77: Add fail-fast ability catalog load at server startup.
Load content/abilities/*_abilities.json with CI-parity validation, E5M1 four-id gate, DI registration, AAA loader/host tests, and Bruno health check.pull/111/head
parent
07444e45ce
commit
619089ca40
|
|
@ -0,0 +1,25 @@
|
||||||
|
meta {
|
||||||
|
name: GET health (ability catalog boot NEO-77)
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/health
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-77 loads content/abilities/*_abilities.json at startup (fail-fast). No ability 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
using NeonSprawl.Server.Game.Combat;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||||
|
|
||||||
|
internal static class AbilityCatalogTestPaths
|
||||||
|
{
|
||||||
|
internal static string DiscoverRepoAbilitiesDirectory() =>
|
||||||
|
AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
|
||||||
|
internal static string DiscoverRepoAbilityDefSchemaPath() =>
|
||||||
|
AbilityCatalogPathResolution.ResolveAbilityDefSchemaPath(
|
||||||
|
DiscoverRepoAbilitiesDirectory(),
|
||||||
|
configuredSchemaPath: null,
|
||||||
|
contentRootPath: string.Empty);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NeonSprawl.Server.Game.Combat;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||||
|
|
||||||
|
public class AbilityDefinitionCatalogLoaderTests
|
||||||
|
{
|
||||||
|
private const string ValidPrototypeCatalogJson =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"abilities": [
|
||||||
|
{
|
||||||
|
"id": "prototype_pulse",
|
||||||
|
"displayName": "Prototype Pulse",
|
||||||
|
"baseDamage": 25,
|
||||||
|
"cooldownSeconds": 3.0,
|
||||||
|
"abilityKind": "attack"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prototype_guard",
|
||||||
|
"displayName": "Prototype Guard",
|
||||||
|
"baseDamage": 0,
|
||||||
|
"cooldownSeconds": 6.0,
|
||||||
|
"abilityKind": "utility"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prototype_dash",
|
||||||
|
"displayName": "Prototype Dash",
|
||||||
|
"baseDamage": 0,
|
||||||
|
"cooldownSeconds": 4.0,
|
||||||
|
"abilityKind": "movement"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prototype_burst",
|
||||||
|
"displayName": "Prototype Burst",
|
||||||
|
"baseDamage": 40,
|
||||||
|
"cooldownSeconds": 5.0,
|
||||||
|
"abilityKind": "attack"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static (string Root, string AbilitiesDir, string SchemaPath) CreateTempContentLayout()
|
||||||
|
{
|
||||||
|
var root = Directory.CreateTempSubdirectory("neon-sprawl-abilitycat-");
|
||||||
|
var abilitiesDir = Path.Combine(root.FullName, "content", "abilities");
|
||||||
|
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||||
|
Directory.CreateDirectory(abilitiesDir);
|
||||||
|
Directory.CreateDirectory(schemaDir);
|
||||||
|
var schemaPath = Path.Combine(schemaDir, "ability-def.schema.json");
|
||||||
|
File.Copy(AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath(), schemaPath, overwrite: true);
|
||||||
|
return (root.FullName, abilitiesDir, schemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteCatalog(string abilitiesDir, string catalogJson) =>
|
||||||
|
File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8);
|
||||||
|
|
||||||
|
private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId)
|
||||||
|
{
|
||||||
|
var abilities = catalogRoot["abilities"] as JsonArray
|
||||||
|
?? throw new InvalidOperationException("expected abilities array");
|
||||||
|
foreach (var node in abilities)
|
||||||
|
{
|
||||||
|
if (node is JsonObject row && row["id"]?.GetValue<string>() == abilityId)
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"ability id not found: {abilityId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbilityDefinitionCatalog LoadCatalog(string abilitiesDir, string schemaPath) =>
|
||||||
|
AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
|
||||||
|
// Act
|
||||||
|
var catalog = LoadCatalog(abilitiesDir, schemaPath);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(4, catalog.DistinctAbilityCount);
|
||||||
|
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||||
|
Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse));
|
||||||
|
Assert.NotNull(pulse);
|
||||||
|
Assert.Equal(25, pulse!.BaseDamage);
|
||||||
|
Assert.Equal(3.0, pulse.CooldownSeconds);
|
||||||
|
Assert.Equal("attack", pulse.AbilityKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenAbilitiesIsNotArray()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(abilitiesDir, "bad_abilities.json"),
|
||||||
|
"""{"schemaVersion": 1, "abilities": "nope"}""",
|
||||||
|
Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("expected top-level 'abilities' array", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(abilitiesDir, "bad_abilities.json"),
|
||||||
|
"""{"schemaVersion": 2, "abilities": []}""",
|
||||||
|
Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenDuplicateAbilityIdAcrossFiles()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(abilitiesDir, "extra_abilities.json"),
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"abilities": [
|
||||||
|
{
|
||||||
|
"id": "prototype_pulse",
|
||||||
|
"displayName": "Duplicate Pulse",
|
||||||
|
"baseDamage": 1,
|
||||||
|
"cooldownSeconds": 1.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("duplicate ability id 'prototype_pulse'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenE5M1GateFails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||||
|
?? throw new InvalidOperationException("expected object root");
|
||||||
|
var abilities = root["abilities"] as JsonArray
|
||||||
|
?? throw new InvalidOperationException("expected abilities array");
|
||||||
|
abilities.RemoveAt(abilities.Count - 1);
|
||||||
|
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenBaseDamageIsNegative()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||||
|
?? throw new InvalidOperationException("expected object root");
|
||||||
|
GetAbilityRow(root, "prototype_pulse")["baseDamage"] = -1;
|
||||||
|
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("baseDamage", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenCooldownSecondsIsZero()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||||
|
?? throw new InvalidOperationException("expected object root");
|
||||||
|
GetAbilityRow(root, "prototype_pulse")["cooldownSeconds"] = 0;
|
||||||
|
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("cooldownSeconds", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenDisplayNameIsEmpty()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||||
|
?? throw new InvalidOperationException("expected object root");
|
||||||
|
GetAbilityRow(root, "prototype_pulse")["displayName"] = "";
|
||||||
|
WriteCatalog(abilitiesDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("displayName", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenNoAbilityCatalogFiles()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("no *_abilities.json files", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenJsonIsInvalid()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(abilitiesDir, "bad_abilities.json"),
|
||||||
|
"{not json",
|
||||||
|
Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenAbilitiesDirectoryMissing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-abilities-" + Guid.NewGuid().ToString("n"));
|
||||||
|
var schemaPath = AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath();
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() =>
|
||||||
|
AbilityDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenSchemaFileMissing()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, abilitiesDir, _) = CreateTempContentLayout();
|
||||||
|
var missingSchema = Path.Combine(abilitiesDir, "missing-ability-def.schema.json");
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() =>
|
||||||
|
AbilityDefinitionCatalogLoader.Load(abilitiesDir, missingSchema, NullLogger.Instance));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(missingSchema, 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<AbilityDefinitionCatalog>();
|
||||||
|
Assert.Equal(4, catalog.DistinctAbilityCount);
|
||||||
|
Assert.True(catalog.TryGetAbility("prototype_burst", out var burst));
|
||||||
|
Assert.Equal(40, burst!.BaseDamage);
|
||||||
|
Assert.Equal(5.0, burst.CooldownSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-abilities-" + Guid.NewGuid().ToString("n"));
|
||||||
|
Directory.CreateDirectory(badDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() =>
|
||||||
|
{
|
||||||
|
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||||
|
b.UseSetting("Content:AbilitiesDirectory", badDir));
|
||||||
|
factory.CreateClient();
|
||||||
|
});
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(ex);
|
||||||
|
Assert.Contains("Ability catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(badDir))
|
||||||
|
Directory.Delete(badDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
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.Combat;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Gathering;
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
|
@ -41,11 +42,15 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
||||||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||||
|
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
|
|
|
||||||
|
|
@ -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.Combat;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Gathering;
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
|
@ -39,11 +40,15 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||||
|
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||||
|
|
||||||
builder.ConfigureAppConfiguration((_, config) =>
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
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.Combat;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
@ -33,9 +34,13 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
|
||||||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||||
|
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
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.Combat;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
@ -33,9 +34,13 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
|
||||||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||||
|
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
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.Combat;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Gathering;
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
|
@ -42,11 +43,15 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||||
|
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Combat;
|
||||||
|
|
||||||
|
/// <summary>Resolves ability catalog paths for local dev, tests, and container layouts (NEO-77).</summary>
|
||||||
|
public static class AbilityCatalogPathResolution
|
||||||
|
{
|
||||||
|
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/abilities</c> directory.</summary>
|
||||||
|
public static string? TryDiscoverAbilitiesDirectory(string startDirectory)
|
||||||
|
{
|
||||||
|
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir.FullName, "content", "abilities");
|
||||||
|
if (Directory.Exists(candidate))
|
||||||
|
return Path.GetFullPath(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the abilities catalog directory.
|
||||||
|
/// Empty <paramref name="configuredAbilitiesDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolveAbilitiesDirectory(string? configuredAbilitiesDirectory, string contentRootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredAbilitiesDirectory))
|
||||||
|
{
|
||||||
|
var discovered = TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory);
|
||||||
|
if (discovered is not null)
|
||||||
|
return discovered;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Content:AbilitiesDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/abilities'). " +
|
||||||
|
"Set Content:AbilitiesDirectory in configuration or environment (e.g. Content__AbilitiesDirectory).");
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = configuredAbilitiesDirectory.Trim();
|
||||||
|
if (Path.IsPathRooted(trimmed))
|
||||||
|
return Path.GetFullPath(trimmed);
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a single ability row (Draft 2020-12).</summary>
|
||||||
|
public static string ResolveAbilityDefSchemaPath(
|
||||||
|
string abilitiesDirectory,
|
||||||
|
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(abilitiesDirectory, "..", "schemas", "ability-def.schema.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Combat;
|
||||||
|
|
||||||
|
/// <summary>DI registration for the fail-fast ability catalog (NEO-77).</summary>
|
||||||
|
public static class AbilityCatalogServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="AbilityDefinitionCatalog"/> as a singleton.</summary>
|
||||||
|
public static IServiceCollection AddAbilityDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<ContentPathsOptions>()
|
||||||
|
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton<AbilityDefinitionCatalog>(sp =>
|
||||||
|
{
|
||||||
|
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||||
|
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||||
|
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("NeonSprawl.Server.Game.Combat.AbilityCatalog");
|
||||||
|
|
||||||
|
var abilitiesDir = AbilityCatalogPathResolution.ResolveAbilitiesDirectory(
|
||||||
|
opts.AbilitiesDirectory,
|
||||||
|
hostEnv.ContentRootPath);
|
||||||
|
var schemaPath = AbilityCatalogPathResolution.ResolveAbilityDefSchemaPath(
|
||||||
|
abilitiesDir,
|
||||||
|
opts.AbilityDefSchemaPath,
|
||||||
|
hostEnv.ContentRootPath);
|
||||||
|
|
||||||
|
return AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Combat;
|
||||||
|
|
||||||
|
/// <summary>One validated <c>AbilityDef</c> row from <c>content/abilities/*_abilities.json</c> (NEO-77).</summary>
|
||||||
|
public sealed record AbilityDefRow(
|
||||||
|
string Id,
|
||||||
|
string DisplayName,
|
||||||
|
int BaseDamage,
|
||||||
|
double CooldownSeconds,
|
||||||
|
string? AbilityKind);
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Combat;
|
||||||
|
|
||||||
|
/// <summary>In-memory ability catalog loaded at startup (NEO-77). Game callers should use <c>IAbilityDefinitionRegistry</c> (NEO-79).</summary>
|
||||||
|
public sealed class AbilityDefinitionCatalog(
|
||||||
|
string abilitiesDirectory,
|
||||||
|
IReadOnlyDictionary<string, AbilityDefRow> byId,
|
||||||
|
int catalogJsonFileCount)
|
||||||
|
{
|
||||||
|
/// <summary>Absolute path to the directory that was enumerated for <c>*_abilities.json</c> catalogs.</summary>
|
||||||
|
public string AbilitiesDirectory { get; } = abilitiesDirectory;
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, AbilityDefRow> ById { get; } = new ReadOnlyDictionary<string, AbilityDefRow>(new Dictionary<string, AbilityDefRow>(byId, StringComparer.Ordinal));
|
||||||
|
|
||||||
|
public int DistinctAbilityCount => ById.Count;
|
||||||
|
|
||||||
|
/// <summary>Number of <c>*_abilities.json</c> files under <see cref="AbilitiesDirectory"/>.</summary>
|
||||||
|
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
|
||||||
|
|
||||||
|
/// <summary>Resolves a catalog row by stable ability <paramref name="id"/>.</summary>
|
||||||
|
public bool TryGetAbility(string id, out AbilityDefRow? row) =>
|
||||||
|
ById.TryGetValue(id, out row);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Json.Schema;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Combat;
|
||||||
|
|
||||||
|
/// <summary>Loads and validates <c>content/abilities/*_abilities.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-77).</summary>
|
||||||
|
public static class AbilityDefinitionCatalogLoader
|
||||||
|
{
|
||||||
|
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||||
|
public static AbilityDefinitionCatalog Load(string abilitiesDirectory, string schemaPath, ILogger logger)
|
||||||
|
{
|
||||||
|
abilitiesDirectory = Path.GetFullPath(abilitiesDirectory);
|
||||||
|
schemaPath = Path.GetFullPath(schemaPath);
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
if (!File.Exists(schemaPath))
|
||||||
|
errors.Add($"error: missing schema file {schemaPath}");
|
||||||
|
|
||||||
|
if (!Directory.Exists(abilitiesDirectory))
|
||||||
|
errors.Add($"error: missing directory {abilitiesDirectory}");
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var jsonFiles = Directory.GetFiles(abilitiesDirectory, "*_abilities.json", SearchOption.TopDirectoryOnly)
|
||||||
|
.OrderBy(p => p, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (jsonFiles.Length == 0)
|
||||||
|
errors.Add($"error: no *_abilities.json files under {abilitiesDirectory}");
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
|
||||||
|
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||||
|
|
||||||
|
var abilityIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var rows = new Dictionary<string, AbilityDefRow>(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) ||
|
||||||
|
schemaVersion != 1)
|
||||||
|
{
|
||||||
|
var got = schemaVersionNode?.ToJsonString() ?? "null";
|
||||||
|
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var abilitiesNode = rootObj["abilities"];
|
||||||
|
if (abilitiesNode is not JsonArray abilitiesArray)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: expected top-level 'abilities' array");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < abilitiesArray.Count; i++)
|
||||||
|
{
|
||||||
|
var ability = abilitiesArray[i];
|
||||||
|
if (ability is not JsonObject rowObj)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: abilities[{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} abilities[{i}] (root): schema validation failed");
|
||||||
|
|
||||||
|
errors.AddRange(schemaMsgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rowSchemaErrors = schemaMsgs.Count;
|
||||||
|
|
||||||
|
var aid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||||
|
if (aid is not null && rowSchemaErrors == 0)
|
||||||
|
{
|
||||||
|
if (abilityIdToSourceFile.TryGetValue(aid, out var prevPath))
|
||||||
|
{
|
||||||
|
errors.Add($"error: duplicate ability id '{aid}' in {prevPath} and {path}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
abilityIdToSourceFile[aid] = path;
|
||||||
|
rows[aid] = ParseRow(rowObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var e5m1 = PrototypeE5M1AbilityCatalogRules.TryGetE5M1GateError(abilityIdToSourceFile);
|
||||||
|
if (e5m1 is not null)
|
||||||
|
{
|
||||||
|
errors.Add(e5m1);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logger.IsEnabled(LogLevel.Information))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Loaded ability catalog from {AbilitiesDirectory}: {AbilityCount} ability(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||||
|
abilitiesDirectory,
|
||||||
|
rows.Count,
|
||||||
|
jsonFiles.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AbilityDefinitionCatalog(abilitiesDirectory, rows, jsonFiles.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbilityDefRow ParseRow(JsonObject rowObj)
|
||||||
|
{
|
||||||
|
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||||
|
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||||
|
var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>();
|
||||||
|
var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>();
|
||||||
|
string? abilityKind = null;
|
||||||
|
if (rowObj["abilityKind"] is JsonValue abilityKindValue)
|
||||||
|
abilityKind = abilityKindValue.GetValue<string>();
|
||||||
|
|
||||||
|
return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
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} abilities[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ThrowIfAny(List<string> errors)
|
||||||
|
{
|
||||||
|
if (errors.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("Ability catalog validation failed:");
|
||||||
|
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||||
|
sb.AppendLine(e);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Combat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from <c>scripts/validate_content.py</c>
|
||||||
|
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c> / <c>_prototype_e5m1_ability_gate</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class PrototypeE5M1AbilityCatalogRules
|
||||||
|
{
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M1_ABILITY_IDS</c>.</summary>
|
||||||
|
public static readonly FrozenSet<string> ExpectedAbilityIds = FrozenSet.ToFrozenSet(
|
||||||
|
[
|
||||||
|
"prototype_pulse",
|
||||||
|
"prototype_guard",
|
||||||
|
"prototype_dash",
|
||||||
|
"prototype_burst",
|
||||||
|
],
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error if the E5M1 ability contract fails, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetE5M1GateError(IReadOnlyDictionary<string, string> abilityIdToSourceFile)
|
||||||
|
{
|
||||||
|
var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||||
|
if (!ids.SetEquals(ExpectedAbilityIds))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
"error: prototype E5M1 expects exactly ability ids " +
|
||||||
|
$"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||||
|
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,4 +76,16 @@ public sealed class ContentPathsOptions
|
||||||
/// When unset, resolved as <c>{parent of recipes directory}/schemas/recipe-io-row.schema.json</c>.
|
/// When unset, resolved as <c>{parent of recipes directory}/schemas/recipe-io-row.schema.json</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? RecipeIoRowSchemaPath { get; set; }
|
public string? RecipeIoRowSchemaPath { 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/abilities</c> directory.
|
||||||
|
/// </summary>
|
||||||
|
public string? AbilitiesDirectory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override for <c>ability-def.schema.json</c>.
|
||||||
|
/// When unset, resolved as <c>{parent of abilities directory}/schemas/ability-def.schema.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? AbilityDefSchemaPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using NeonSprawl.Server.Game.Combat;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Gathering;
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Interaction;
|
using NeonSprawl.Server.Game.Interaction;
|
||||||
|
|
@ -19,6 +20,7 @@ builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
|
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddResourceNodeCatalog(builder.Configuration);
|
builder.Services.AddResourceNodeCatalog(builder.Configuration);
|
||||||
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
|
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
|
||||||
|
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddMasteryCatalog(builder.Configuration);
|
builder.Services.AddMasteryCatalog(builder.Configuration);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
@ -26,6 +28,7 @@ _ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
|
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
|
||||||
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
|
||||||
|
_ = app.Services.GetRequiredService<AbilityDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@
|
||||||
"ItemDefSchemaPath": "",
|
"ItemDefSchemaPath": "",
|
||||||
"RecipesDirectory": "",
|
"RecipesDirectory": "",
|
||||||
"RecipeDefSchemaPath": "",
|
"RecipeDefSchemaPath": "",
|
||||||
"RecipeIoRowSchemaPath": ""
|
"RecipeIoRowSchemaPath": "",
|
||||||
|
"AbilitiesDirectory": "",
|
||||||
|
"AbilityDefSchemaPath": ""
|
||||||
},
|
},
|
||||||
"Game": {
|
"Game": {
|
||||||
"DevPlayerId": "dev-local-1",
|
"DevPlayerId": "dev-local-1",
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,19 @@ On startup the host loads every **`*_recipes.json`** under the recipes directory
|
||||||
|
|
||||||
On success, **Information** logs include the resolved recipes directory path, distinct recipe count, and catalog file count. Game code should use **`IRecipeDefinitionRegistry`** for lookups (NEO-67). The catalog singleton remains for fail-fast startup only; do not inject **`RecipeDefinitionCatalog`** in new game code.
|
On success, **Information** logs include the resolved recipes directory path, distinct recipe count, and catalog file count. Game code should use **`IRecipeDefinitionRegistry`** for lookups (NEO-67). The catalog singleton remains for fail-fast startup only; do not inject **`RecipeDefinitionCatalog`** in new game code.
|
||||||
|
|
||||||
|
## Ability catalog (`content/abilities`, NEO-77)
|
||||||
|
|
||||||
|
On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype E5M1** four-id 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:AbilitiesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/abilities`**. |
|
||||||
|
| **`Content:AbilityDefSchemaPath`** | Optional override for **`ability-def.schema.json`**. When unset, **`{parent of abilities directory}/schemas/ability-def.schema.json`**. |
|
||||||
|
|
||||||
|
**Docker / CI:** include **`content/abilities`** and **`content/schemas/ability-def.schema.json`** in the mounted **`content/`** tree; set **`Content__AbilitiesDirectory`** when layout differs.
|
||||||
|
|
||||||
|
On success, **Information** logs include the resolved abilities directory path, distinct ability count, and catalog file count. Game code should use **`IAbilityDefinitionRegistry`** for lookups (NEO-79). The catalog singleton remains for fail-fast startup only; **`PrototypeAbilityRegistry`** hotbar/cast allowlist is unchanged until NEO-79.
|
||||||
|
|
||||||
## Recipe definitions (NEO-68)
|
## Recipe definitions (NEO-68)
|
||||||
|
|
||||||
**`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`.
|
**`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue