400 lines
16 KiB
C#
400 lines
16 KiB
C#
using System.Text.Json.Nodes;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using NeonSprawl.Server.Game.Combat;
|
|
|
|
namespace NeonSprawl.Server.Tests.Game.Combat;
|
|
|
|
public class AbilityDefinitionCatalogLoaderTests
|
|
{
|
|
private const string ValidPrototypeCatalogJson =
|
|
"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"abilities": [
|
|
{
|
|
"id": "prototype_pulse",
|
|
"displayName": "Prototype Pulse",
|
|
"baseDamage": 25,
|
|
"maxRange": 6.0,
|
|
"cooldownSeconds": 3.0,
|
|
"abilityKind": "attack"
|
|
},
|
|
{
|
|
"id": "prototype_guard",
|
|
"displayName": "Prototype Guard",
|
|
"baseDamage": 0,
|
|
"maxRange": 6.0,
|
|
"cooldownSeconds": 6.0,
|
|
"abilityKind": "utility"
|
|
},
|
|
{
|
|
"id": "prototype_dash",
|
|
"displayName": "Prototype Dash",
|
|
"baseDamage": 0,
|
|
"maxRange": 6.0,
|
|
"cooldownSeconds": 4.0,
|
|
"abilityKind": "movement"
|
|
},
|
|
{
|
|
"id": "prototype_burst",
|
|
"displayName": "Prototype Burst",
|
|
"baseDamage": 40,
|
|
"maxRange": 6.0,
|
|
"cooldownSeconds": 5.0,
|
|
"abilityKind": "attack"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
private const string ValidNpcAttackAbilitiesJson =
|
|
"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"abilities": [
|
|
{
|
|
"id": "prototype_npc_melee_strike",
|
|
"displayName": "NPC Melee Strike",
|
|
"baseDamage": 15,
|
|
"maxRange": 2.0,
|
|
"cooldownSeconds": 3.0,
|
|
"abilityKind": "attack"
|
|
},
|
|
{
|
|
"id": "prototype_npc_ranged_shot",
|
|
"displayName": "NPC Ranged Shot",
|
|
"baseDamage": 12,
|
|
"maxRange": 10.0,
|
|
"cooldownSeconds": 4.0,
|
|
"abilityKind": "attack"
|
|
},
|
|
{
|
|
"id": "prototype_npc_elite_slam",
|
|
"displayName": "NPC Elite Slam",
|
|
"baseDamage": 25,
|
|
"maxRange": 4.0,
|
|
"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 void WriteFullPrototypeCatalog(string abilitiesDir)
|
|
{
|
|
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
|
|
File.WriteAllText(
|
|
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
|
|
ValidNpcAttackAbilitiesJson,
|
|
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();
|
|
WriteFullPrototypeCatalog(abilitiesDir);
|
|
// Act
|
|
var catalog = LoadCatalog(abilitiesDir, schemaPath);
|
|
// Assert
|
|
Assert.Equal(7, catalog.DistinctAbilityCount);
|
|
Assert.Equal(2, 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();
|
|
WriteFullPrototypeCatalog(abilitiesDir);
|
|
File.WriteAllText(
|
|
Path.Combine(abilitiesDir, "extra_abilities.json"),
|
|
"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"abilities": [
|
|
{
|
|
"id": "prototype_pulse",
|
|
"displayName": "Duplicate Pulse",
|
|
"baseDamage": 1,
|
|
"maxRange": 6.0,
|
|
"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_WhenE5M1GateFailsWithMissingId()
|
|
{
|
|
// 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());
|
|
File.WriteAllText(
|
|
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
|
|
ValidNpcAttackAbilitiesJson,
|
|
Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("prototype ability catalog expects exactly ability ids", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenE5M1GateFailsWithExtraId()
|
|
{
|
|
// Arrange
|
|
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
|
|
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
|
?? throw new InvalidOperationException("expected object root");
|
|
GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra";
|
|
WriteCatalog(abilitiesDir, root.ToJsonString());
|
|
File.WriteAllText(
|
|
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
|
|
ValidNpcAttackAbilitiesJson,
|
|
Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("prototype ability catalog expects exactly ability ids", ioe.Message, StringComparison.Ordinal);
|
|
Assert.Contains("prototype_extra", 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(7, 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);
|
|
}
|
|
}
|
|
}
|