384 lines
16 KiB
C#
384 lines
16 KiB
C#
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,
|
|
"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);
|
|
}
|
|
}
|
|
}
|