neon-sprawl/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalo...

377 lines
16 KiB
C#

using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
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,
"attackAbilityId": "prototype_npc_melee_strike"
},
{
"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,
"attackAbilityId": "prototype_npc_ranged_shot"
},
{
"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,
"attackAbilityId": "prototype_npc_elite_slam"
}
]
}
""";
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,
"attackAbilityId": "prototype_npc_melee_strike"
}
]
}
""",
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);
}
}
}