NEO-88: Fail-fast NPC behavior catalog load at server startup.
Mirror NEO-77 ability loader: JsonSchema.Net validation, E5M2 three-id gate, leash > aggro numeric gate, DI singleton, Bruno health smoke, and 16 AAA loader/host tests.pull/125/head
parent
7730bde353
commit
83282e76f0
|
|
@ -0,0 +1,25 @@
|
|||
meta {
|
||||
name: GET health (npc behavior catalog boot NEO-88)
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-88 loads content/npc-behaviors/*_npc_behaviors.json at startup (fail-fast). No NPC behavior 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,3 @@
|
|||
meta {
|
||||
name: npc-behavior-catalog
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ using NeonSprawl.Server.Game.Crafting;
|
|||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Npgsql;
|
||||
|
|
@ -45,12 +46,16 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
|||
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.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors 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("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
using NeonSprawl.Server.Game.Npc;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||
|
||||
internal static class NpcBehaviorCatalogTestPaths
|
||||
{
|
||||
internal static string DiscoverRepoNpcBehaviorsDirectory() =>
|
||||
NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
|
||||
internal static string DiscoverRepoNpcBehaviorDefSchemaPath() =>
|
||||
NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorDefSchemaPath(
|
||||
DiscoverRepoNpcBehaviorsDirectory(),
|
||||
configuredSchemaPath: null,
|
||||
contentRootPath: string.Empty);
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
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.Npc;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||
|
||||
public class NpcBehaviorDefinitionCatalogLoaderTests
|
||||
{
|
||||
private const string ValidPrototypeCatalogJson =
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"npcBehaviors": [
|
||||
{
|
||||
"id": "prototype_melee_pressure",
|
||||
"displayName": "Melee Pressure",
|
||||
"archetypeKind": "melee_pressure",
|
||||
"maxHp": 100,
|
||||
"aggroRadius": 8.0,
|
||||
"leashRadius": 16.0,
|
||||
"telegraphWindupSeconds": 1.5,
|
||||
"attackDamage": 15,
|
||||
"attackCooldownSeconds": 3.0
|
||||
},
|
||||
{
|
||||
"id": "prototype_ranged_control",
|
||||
"displayName": "Ranged Control",
|
||||
"archetypeKind": "ranged_control",
|
||||
"maxHp": 80,
|
||||
"aggroRadius": 10.0,
|
||||
"leashRadius": 20.0,
|
||||
"telegraphWindupSeconds": 2.0,
|
||||
"attackDamage": 12,
|
||||
"attackCooldownSeconds": 4.0
|
||||
},
|
||||
{
|
||||
"id": "prototype_elite_mini_boss",
|
||||
"displayName": "Elite Mini-Boss",
|
||||
"archetypeKind": "elite_mini_boss",
|
||||
"maxHp": 200,
|
||||
"aggroRadius": 8.0,
|
||||
"leashRadius": 18.0,
|
||||
"telegraphWindupSeconds": 2.5,
|
||||
"attackDamage": 25,
|
||||
"attackCooldownSeconds": 5.0
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static (string Root, string NpcBehaviorsDir, string SchemaPath) CreateTempContentLayout()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-npcbehaviorcat-");
|
||||
var npcBehaviorsDir = Path.Combine(root.FullName, "content", "npc-behaviors");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(npcBehaviorsDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var schemaPath = Path.Combine(schemaDir, "npc-behavior-def.schema.json");
|
||||
File.Copy(NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorDefSchemaPath(), schemaPath, overwrite: true);
|
||||
return (root.FullName, npcBehaviorsDir, schemaPath);
|
||||
}
|
||||
|
||||
private static void WriteCatalog(string npcBehaviorsDir, string catalogJson) =>
|
||||
File.WriteAllText(Path.Combine(npcBehaviorsDir, "prototype_npc_behaviors.json"), catalogJson, Encoding.UTF8);
|
||||
|
||||
private static JsonObject GetBehaviorRow(JsonObject catalogRoot, string behaviorId)
|
||||
{
|
||||
var npcBehaviors = catalogRoot["npcBehaviors"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected npcBehaviors array");
|
||||
foreach (var node in npcBehaviors)
|
||||
{
|
||||
if (node is JsonObject row && row["id"]?.GetValue<string>() == behaviorId)
|
||||
return row;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"npc behavior id not found: {behaviorId}");
|
||||
}
|
||||
|
||||
private static NpcBehaviorDefinitionCatalog LoadCatalog(string npcBehaviorsDir, string schemaPath) =>
|
||||
NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, NullLogger.Instance);
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(npcBehaviorsDir, ValidPrototypeCatalogJson);
|
||||
// Act
|
||||
var catalog = LoadCatalog(npcBehaviorsDir, schemaPath);
|
||||
// Assert
|
||||
Assert.Equal(3, catalog.DistinctBehaviorCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.TryGetBehavior("prototype_melee_pressure", out var melee));
|
||||
Assert.NotNull(melee);
|
||||
Assert.Equal("melee_pressure", melee!.ArchetypeKind);
|
||||
Assert.Equal(100, melee.MaxHp);
|
||||
Assert.Equal(8.0, melee.AggroRadius);
|
||||
Assert.Equal(16.0, melee.LeashRadius);
|
||||
Assert.Equal(15, melee.AttackDamage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenNpcBehaviorsIsNotArray()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"),
|
||||
"""{"schemaVersion": 1, "npcBehaviors": "nope"}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_npc_behaviors.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("expected top-level 'npcBehaviors' array", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"),
|
||||
"""{"schemaVersion": 2, "npcBehaviors": []}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDuplicateBehaviorIdAcrossFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(npcBehaviorsDir, ValidPrototypeCatalogJson);
|
||||
File.WriteAllText(
|
||||
Path.Combine(npcBehaviorsDir, "extra_npc_behaviors.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"npcBehaviors": [
|
||||
{
|
||||
"id": "prototype_melee_pressure",
|
||||
"displayName": "Duplicate Melee",
|
||||
"archetypeKind": "melee_pressure",
|
||||
"maxHp": 1,
|
||||
"aggroRadius": 1.0,
|
||||
"leashRadius": 2.0,
|
||||
"telegraphWindupSeconds": 1.0,
|
||||
"attackDamage": 1,
|
||||
"attackCooldownSeconds": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("duplicate npc behavior id 'prototype_melee_pressure'", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenE5M2GateFailsWithMissingId()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
var npcBehaviors = root["npcBehaviors"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected npcBehaviors array");
|
||||
npcBehaviors.RemoveAt(npcBehaviors.Count - 1);
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E5M2 expects exactly npc behavior ids", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenE5M2GateFailsWithExtraId()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_elite_mini_boss")["id"] = "prototype_extra";
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E5M2 expects exactly npc behavior ids", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenLeashRadiusIsNotGreaterThanAggroRadius()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_melee_pressure")["leashRadius"] = 8.0;
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("leashRadius 8 must be > aggroRadius 8", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenAggroRadiusIsZero()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_melee_pressure")["aggroRadius"] = 0;
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("aggroRadius", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenMaxHpIsZero()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_melee_pressure")["maxHp"] = 0;
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("maxHp", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDisplayNameIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_melee_pressure")["displayName"] = "";
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("displayName", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenNoNpcBehaviorCatalogFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("no *_npc_behaviors.json files", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenJsonIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"),
|
||||
"{not json",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_npc_behaviors.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenNpcBehaviorsDirectoryMissing()
|
||||
{
|
||||
// Arrange
|
||||
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-npc-behaviors-" + Guid.NewGuid().ToString("n"));
|
||||
var schemaPath = NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorDefSchemaPath();
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
NpcBehaviorDefinitionCatalogLoader.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 (_, npcBehaviorsDir, _) = CreateTempContentLayout();
|
||||
var missingSchema = Path.Combine(npcBehaviorsDir, "missing-npc-behavior-def.schema.json");
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, 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<NpcBehaviorDefinitionCatalog>();
|
||||
Assert.Equal(3, catalog.DistinctBehaviorCount);
|
||||
Assert.True(catalog.TryGetBehavior("prototype_elite_mini_boss", out var elite));
|
||||
Assert.Equal(200, elite!.MaxHp);
|
||||
Assert.Equal(25, elite.AttackDamage);
|
||||
Assert.Equal(5.0, elite.AttackCooldownSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-npc-behaviors-" + Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(badDir);
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||
b.UseSetting("Content:NpcBehaviorsDirectory", badDir));
|
||||
factory.CreateClient();
|
||||
});
|
||||
// Assert
|
||||
Assert.NotNull(ex);
|
||||
Assert.Contains("NPC behavior catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(badDir))
|
||||
Directory.Delete(badDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Crafting;
|
|||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
|
@ -43,12 +44,16 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
|
|||
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.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors 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("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.AbilityInput;
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Npgsql;
|
||||
|
|
@ -37,10 +38,14 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
|
|||
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.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.AbilityInput;
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Npgsql;
|
||||
|
|
@ -37,10 +38,14 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
|
|||
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.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ using NeonSprawl.Server.Game.Gigs;
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Npgsql;
|
||||
|
||||
|
|
@ -47,12 +48,16 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
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.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors 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("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>Resolves NPC behavior catalog paths for local dev, tests, and container layouts (NEO-88).</summary>
|
||||
public static class NpcBehaviorCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/npc-behaviors</c> directory.</summary>
|
||||
public static string? TryDiscoverNpcBehaviorsDirectory(string startDirectory)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "content", "npc-behaviors");
|
||||
if (Directory.Exists(candidate))
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the npc-behaviors catalog directory.
|
||||
/// Empty <paramref name="configuredNpcBehaviorsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
public static string ResolveNpcBehaviorsDirectory(string? configuredNpcBehaviorsDirectory, string contentRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredNpcBehaviorsDirectory))
|
||||
{
|
||||
var discovered = TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory);
|
||||
if (discovered is not null)
|
||||
return discovered;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Content:NpcBehaviorsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/npc-behaviors'). " +
|
||||
"Set Content:NpcBehaviorsDirectory in configuration or environment (e.g. Content__NpcBehaviorsDirectory).");
|
||||
}
|
||||
|
||||
var trimmed = configuredNpcBehaviorsDirectory.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a single NPC behavior row (Draft 2020-12).</summary>
|
||||
public static string ResolveNpcBehaviorDefSchemaPath(
|
||||
string npcBehaviorsDirectory,
|
||||
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(npcBehaviorsDirectory, "..", "schemas", "npc-behavior-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.Npc;
|
||||
|
||||
/// <summary>DI registration for the fail-fast NPC behavior catalog (NEO-88).</summary>
|
||||
public static class NpcBehaviorCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="NpcBehaviorDefinitionCatalog"/> as a singleton.</summary>
|
||||
public static IServiceCollection AddNpcBehaviorDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||
|
||||
services.AddSingleton<NpcBehaviorDefinitionCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("NeonSprawl.Server.Game.Npc.NpcBehaviorCatalog");
|
||||
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorsDirectory(
|
||||
opts.NpcBehaviorsDirectory,
|
||||
hostEnv.ContentRootPath);
|
||||
var schemaPath = NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorDefSchemaPath(
|
||||
npcBehaviorsDir,
|
||||
opts.NpcBehaviorDefSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
|
||||
return NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>One validated <c>NpcBehaviorDef</c> row from <c>content/npc-behaviors/*_npc_behaviors.json</c> (NEO-88).</summary>
|
||||
public sealed record NpcBehaviorDefRow(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string ArchetypeKind,
|
||||
int MaxHp,
|
||||
double AggroRadius,
|
||||
double LeashRadius,
|
||||
double TelegraphWindupSeconds,
|
||||
int AttackDamage,
|
||||
double AttackCooldownSeconds);
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>In-memory NPC behavior catalog loaded at startup (NEO-88). Game callers should use INpcBehaviorDefinitionRegistry (NEO-89).</summary>
|
||||
public sealed class NpcBehaviorDefinitionCatalog(
|
||||
string npcBehaviorsDirectory,
|
||||
IReadOnlyDictionary<string, NpcBehaviorDefRow> byId,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
/// <summary>Absolute path to the directory that was enumerated for <c>*_npc_behaviors.json</c> catalogs.</summary>
|
||||
public string NpcBehaviorsDirectory { get; } = npcBehaviorsDirectory;
|
||||
|
||||
public IReadOnlyDictionary<string, NpcBehaviorDefRow> ById { get; } =
|
||||
new ReadOnlyDictionary<string, NpcBehaviorDefRow>(new Dictionary<string, NpcBehaviorDefRow>(byId, StringComparer.Ordinal));
|
||||
|
||||
public int DistinctBehaviorCount => ById.Count;
|
||||
|
||||
/// <summary>Number of <c>*_npc_behaviors.json</c> files under <see cref="NpcBehaviorsDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
|
||||
|
||||
/// <summary>Resolves a catalog row by stable behavior <paramref name="id"/>.</summary>
|
||||
public bool TryGetBehavior(string id, out NpcBehaviorDefRow? row) =>
|
||||
ById.TryGetValue(id, out row);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>Loads and validates <c>content/npc-behaviors/*_npc_behaviors.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-88).</summary>
|
||||
public static class NpcBehaviorDefinitionCatalogLoader
|
||||
{
|
||||
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||
public static NpcBehaviorDefinitionCatalog Load(string npcBehaviorsDirectory, string schemaPath, ILogger logger)
|
||||
{
|
||||
npcBehaviorsDirectory = Path.GetFullPath(npcBehaviorsDirectory);
|
||||
schemaPath = Path.GetFullPath(schemaPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(schemaPath))
|
||||
errors.Add($"error: missing schema file {schemaPath}");
|
||||
|
||||
if (!Directory.Exists(npcBehaviorsDirectory))
|
||||
errors.Add($"error: missing directory {npcBehaviorsDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(npcBehaviorsDirectory, "*_npc_behaviors.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_npc_behaviors.json files under {npcBehaviorsDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
|
||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||
|
||||
var behaviorIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(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 npcBehaviorsNode = rootObj["npcBehaviors"];
|
||||
if (npcBehaviorsNode is not JsonArray npcBehaviorsArray)
|
||||
{
|
||||
errors.Add($"error: {path}: expected top-level 'npcBehaviors' array");
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < npcBehaviorsArray.Count; i++)
|
||||
{
|
||||
var behavior = npcBehaviorsArray[i];
|
||||
if (behavior is not JsonObject rowObj)
|
||||
{
|
||||
errors.Add($"error: {path}: npcBehaviors[{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} npcBehaviors[{i}] (root): schema validation failed");
|
||||
|
||||
errors.AddRange(schemaMsgs);
|
||||
}
|
||||
|
||||
var rowSchemaErrors = schemaMsgs.Count;
|
||||
|
||||
var bid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||
if (bid is not null && rowSchemaErrors == 0)
|
||||
{
|
||||
if (behaviorIdToSourceFile.TryGetValue(bid, out var prevPath))
|
||||
{
|
||||
errors.Add($"error: duplicate npc behavior id '{bid}' in {prevPath} and {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
behaviorIdToSourceFile[bid] = path;
|
||||
rows[bid] = ParseRow(rowObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var e5m2 = PrototypeE5M2NpcBehaviorCatalogRules.TryGetE5M2GateError(behaviorIdToSourceFile);
|
||||
if (e5m2 is not null)
|
||||
{
|
||||
errors.Add(e5m2);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
var numeric = PrototypeE5M2NpcBehaviorCatalogRules.TryGetNumericGateError(rows);
|
||||
if (numeric is not null)
|
||||
{
|
||||
errors.Add(numeric);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Loaded NPC behavior catalog from {NpcBehaviorsDirectory}: {BehaviorCount} behavior(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||
npcBehaviorsDirectory,
|
||||
rows.Count,
|
||||
jsonFiles.Length);
|
||||
}
|
||||
|
||||
return new NpcBehaviorDefinitionCatalog(npcBehaviorsDirectory, rows, jsonFiles.Length);
|
||||
}
|
||||
|
||||
private static NpcBehaviorDefRow ParseRow(JsonObject rowObj)
|
||||
{
|
||||
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||
var archetypeKind = (rowObj["archetypeKind"] as JsonValue)!.GetValue<string>();
|
||||
var maxHp = (rowObj["maxHp"] as JsonValue)!.GetValue<int>();
|
||||
var aggroRadius = (rowObj["aggroRadius"] as JsonValue)!.GetValue<double>();
|
||||
var leashRadius = (rowObj["leashRadius"] as JsonValue)!.GetValue<double>();
|
||||
var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue<double>();
|
||||
var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue<int>();
|
||||
var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue<double>();
|
||||
|
||||
return new NpcBehaviorDefRow(
|
||||
id,
|
||||
displayName,
|
||||
archetypeKind,
|
||||
maxHp,
|
||||
aggroRadius,
|
||||
leashRadius,
|
||||
telegraphWindupSeconds,
|
||||
attackDamage,
|
||||
attackCooldownSeconds);
|
||||
}
|
||||
|
||||
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} npcBehaviors[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfAny(List<string> errors)
|
||||
{
|
||||
if (errors.Count == 0)
|
||||
return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("NPC behavior 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,50 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Npc;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E5M2 roster + numeric gates (NEO-87 / NEO-88), mirrored from <c>scripts/validate_content.py</c>
|
||||
/// <c>PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS</c>, <c>_prototype_e5m2_npc_behavior_gate</c>, and
|
||||
/// <c>_prototype_e5m2_npc_behavior_numeric_gate</c>.
|
||||
/// </summary>
|
||||
public static class PrototypeE5M2NpcBehaviorCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS</c>.</summary>
|
||||
public static readonly FrozenSet<string> ExpectedBehaviorIds = FrozenSet.ToFrozenSet(
|
||||
[
|
||||
"prototype_melee_pressure",
|
||||
"prototype_ranged_control",
|
||||
"prototype_elite_mini_boss",
|
||||
],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a human-readable error if the E5M2 behavior contract fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetE5M2GateError(IReadOnlyDictionary<string, string> behaviorIdToSourceFile)
|
||||
{
|
||||
var ids = behaviorIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!ids.SetEquals(ExpectedBehaviorIds))
|
||||
{
|
||||
return
|
||||
"error: prototype E5M2 expects exactly npc behavior ids " +
|
||||
$"[{string.Join(", ", ExpectedBehaviorIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error when <c>leashRadius <= aggroRadius</c>, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetNumericGateError(IReadOnlyDictionary<string, NpcBehaviorDefRow> rowsById)
|
||||
{
|
||||
foreach (var (bid, row) in rowsById)
|
||||
{
|
||||
if (row.LeashRadius <= row.AggroRadius)
|
||||
{
|
||||
return
|
||||
$"error: npc behavior '{bid}': leashRadius {row.LeashRadius} must be > aggroRadius {row.AggroRadius}";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -88,4 +88,16 @@ public sealed class ContentPathsOptions
|
|||
/// When unset, resolved as <c>{parent of abilities directory}/schemas/ability-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? AbilityDefSchemaPath { 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/npc-behaviors</c> directory.
|
||||
/// </summary>
|
||||
public string? NpcBehaviorsDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>npc-behavior-def.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? NpcBehaviorDefSchemaPath { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Gigs;
|
|||
using NeonSprawl.Server.Game.Interaction;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
|
|
@ -23,6 +24,7 @@ builder.Services.AddItemDefinitionCatalog(builder.Configuration);
|
|||
builder.Services.AddResourceNodeCatalog(builder.Configuration);
|
||||
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddMasteryCatalog(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
|
@ -31,6 +33,7 @@ _ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
|||
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
|
||||
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<AbilityDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
"RecipeDefSchemaPath": "",
|
||||
"RecipeIoRowSchemaPath": "",
|
||||
"AbilitiesDirectory": "",
|
||||
"AbilityDefSchemaPath": ""
|
||||
"AbilityDefSchemaPath": "",
|
||||
"NpcBehaviorsDirectory": "",
|
||||
"NpcBehaviorDefSchemaPath": ""
|
||||
},
|
||||
"Game": {
|
||||
"DevPlayerId": "dev-local-1",
|
||||
|
|
|
|||
|
|
@ -93,6 +93,19 @@ On startup the host loads every **`*_abilities.json`** under the abilities direc
|
|||
|
||||
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; do not inject **`AbilityDefinitionCatalog`** in new game code. Hotbar loadout and ability cast routes validate ability ids via **`IAbilityDefinitionRegistry.TryNormalizeKnown`** (backed by the loaded catalog, not a separate static allowlist).
|
||||
|
||||
## NPC behavior catalog (`content/npc-behaviors`, NEO-88)
|
||||
|
||||
On startup the host loads every **`*_npc_behaviors.json`** under the npc-behaviors directory, validates each row against **`content/schemas/npc-behavior-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, enforces the **prototype E5M2** three-id roster gate, and requires **`leashRadius > aggroRadius`** per row (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:NpcBehaviorsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/npc-behaviors`**. |
|
||||
| **`Content:NpcBehaviorDefSchemaPath`** | Optional override for **`npc-behavior-def.schema.json`**. When unset, **`{parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json`**. |
|
||||
|
||||
**Docker / CI:** include **`content/npc-behaviors`** and **`content/schemas/npc-behavior-def.schema.json`** in the mounted **`content/`** tree; set **`Content__NpcBehaviorsDirectory`** when layout differs.
|
||||
|
||||
On success, **Information** logs include the resolved npc-behaviors directory path, distinct behavior count, and catalog file count. Game code should use **`INpcBehaviorDefinitionRegistry`** for lookups (NEO-89). The catalog singleton remains for fail-fast startup only; do not inject **`NpcBehaviorDefinitionCatalog`** in new game code.
|
||||
|
||||
## Ability definitions (NEO-78)
|
||||
|
||||
**`GET /game/world/ability-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`abilities`**) backed by **`IAbilityDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`baseDamage`**, **`cooldownSeconds`**, and **`abilityKind`** when present in content. Plan: [NEO-78 implementation plan](../../docs/plans/NEO-78-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/ability-definitions/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue