368 lines
14 KiB
C#
368 lines
14 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NeonSprawl.Server.Game.Encounters;
|
|
using NeonSprawl.Server.Tests;
|
|
using Xunit;
|
|
|
|
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
|
|
|
public class EncounterDefinitionCatalogLoaderTests
|
|
{
|
|
private const string ValidPrototypeCatalogJson =
|
|
"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"encounters": [
|
|
{
|
|
"id": "prototype_combat_pocket",
|
|
"displayName": "Prototype Combat Pocket",
|
|
"completionCriteria": { "kind": "defeat_all_targets" },
|
|
"requiredNpcInstanceIds": [
|
|
"prototype_npc_melee",
|
|
"prototype_npc_ranged",
|
|
"prototype_npc_elite"
|
|
],
|
|
"rewardTableId": "prototype_combat_pocket_clear"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
private static readonly HashSet<string> KnownRewardTableIds =
|
|
["prototype_combat_pocket_clear"];
|
|
|
|
private static (string Root, string EncountersDir, string SchemaPath) CreateTempContentLayout()
|
|
{
|
|
var root = Directory.CreateTempSubdirectory("neon-sprawl-encountercat-");
|
|
var encountersDir = Path.Combine(root.FullName, "content", "encounters");
|
|
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
|
Directory.CreateDirectory(encountersDir);
|
|
Directory.CreateDirectory(schemaDir);
|
|
var schemaPath = Path.Combine(schemaDir, "encounter-def.schema.json");
|
|
File.Copy(EncounterCatalogTestPaths.DiscoverRepoEncounterDefSchemaPath(), schemaPath, overwrite: true);
|
|
return (root.FullName, encountersDir, schemaPath);
|
|
}
|
|
|
|
private static void WriteCatalog(string encountersDir, string catalogJson) =>
|
|
File.WriteAllText(Path.Combine(encountersDir, "prototype_encounters.json"), catalogJson, Encoding.UTF8);
|
|
|
|
private static EncounterDefinitionCatalog LoadCatalog(
|
|
string encountersDir,
|
|
string schemaPath,
|
|
IReadOnlySet<string>? knownRewardTableIds = null) =>
|
|
EncounterDefinitionCatalogLoader.Load(
|
|
encountersDir,
|
|
schemaPath,
|
|
knownRewardTableIds ?? KnownRewardTableIds,
|
|
NullLogger.Instance);
|
|
|
|
[Fact]
|
|
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
|
{
|
|
// Arrange
|
|
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
|
WriteCatalog(encountersDir, ValidPrototypeCatalogJson);
|
|
// Act
|
|
var catalog = LoadCatalog(encountersDir, schemaPath);
|
|
// Assert
|
|
Assert.Equal(1, catalog.DistinctEncounterCount);
|
|
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
|
Assert.True(catalog.TryGetEncounter("prototype_combat_pocket", out var encounter));
|
|
Assert.NotNull(encounter);
|
|
Assert.Equal("Prototype Combat Pocket", encounter!.DisplayName);
|
|
Assert.Equal("defeat_all_targets", encounter.CompletionCriteriaKind);
|
|
Assert.Equal("prototype_combat_pocket_clear", encounter.RewardTableId);
|
|
Assert.Equal(3, encounter.RequiredNpcInstanceIds.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenEncountersIsNotArray()
|
|
{
|
|
// Arrange
|
|
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(
|
|
Path.Combine(encountersDir, "bad_encounters.json"),
|
|
"""{"schemaVersion": 1, "encounters": "nope"}""",
|
|
Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("bad_encounters.json", ioe.Message, StringComparison.Ordinal);
|
|
Assert.Contains("expected top-level 'encounters' array", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
|
{
|
|
// Arrange
|
|
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(
|
|
Path.Combine(encountersDir, "bad_encounters.json"),
|
|
"""{"schemaVersion": 2, "encounters": []}""",
|
|
Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenDuplicateEncounterIdAcrossFiles()
|
|
{
|
|
// Arrange
|
|
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
|
WriteCatalog(encountersDir, ValidPrototypeCatalogJson);
|
|
File.WriteAllText(
|
|
Path.Combine(encountersDir, "extra_encounters.json"),
|
|
"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"encounters": [
|
|
{
|
|
"id": "prototype_combat_pocket",
|
|
"displayName": "Duplicate Encounter",
|
|
"completionCriteria": { "kind": "defeat_all_targets" },
|
|
"requiredNpcInstanceIds": [
|
|
"prototype_npc_melee",
|
|
"prototype_npc_ranged",
|
|
"prototype_npc_elite"
|
|
],
|
|
"rewardTableId": "prototype_combat_pocket_clear"
|
|
}
|
|
]
|
|
}
|
|
""",
|
|
Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("duplicate encounter id 'prototype_combat_pocket'", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenE5M3AllowlistMismatch()
|
|
{
|
|
// Arrange
|
|
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(
|
|
Path.Combine(encountersDir, "prototype_encounters.json"),
|
|
"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"encounters": [
|
|
{
|
|
"id": "wrong_encounter_id",
|
|
"displayName": "Wrong Encounter",
|
|
"completionCriteria": { "kind": "defeat_all_targets" },
|
|
"requiredNpcInstanceIds": [
|
|
"prototype_npc_melee",
|
|
"prototype_npc_ranged",
|
|
"prototype_npc_elite"
|
|
],
|
|
"rewardTableId": "prototype_combat_pocket_clear"
|
|
}
|
|
]
|
|
}
|
|
""",
|
|
Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("prototype E5M3 expects exactly encounter ids", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenRequiredNpcInstanceIdsMismatch()
|
|
{
|
|
// Arrange
|
|
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(
|
|
Path.Combine(encountersDir, "prototype_encounters.json"),
|
|
"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"encounters": [
|
|
{
|
|
"id": "prototype_combat_pocket",
|
|
"displayName": "Prototype Combat Pocket",
|
|
"completionCriteria": { "kind": "defeat_all_targets" },
|
|
"requiredNpcInstanceIds": [
|
|
"prototype_npc_melee",
|
|
"prototype_npc_ranged",
|
|
"prototype_npc_unknown"
|
|
],
|
|
"rewardTableId": "prototype_combat_pocket_clear"
|
|
}
|
|
]
|
|
}
|
|
""",
|
|
Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("requiredNpcInstanceIds must be exactly", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenRewardTableIdUnknown()
|
|
{
|
|
// Arrange
|
|
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(
|
|
Path.Combine(encountersDir, "prototype_encounters.json"),
|
|
"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"encounters": [
|
|
{
|
|
"id": "prototype_combat_pocket",
|
|
"displayName": "Prototype Combat Pocket",
|
|
"completionCriteria": { "kind": "defeat_all_targets" },
|
|
"requiredNpcInstanceIds": [
|
|
"prototype_npc_melee",
|
|
"prototype_npc_ranged",
|
|
"prototype_npc_elite"
|
|
],
|
|
"rewardTableId": "missing_reward_table"
|
|
}
|
|
]
|
|
}
|
|
""",
|
|
Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("rewardTableId 'missing_reward_table' is not in reward table catalogs", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenRowViolatesSchema()
|
|
{
|
|
// Arrange
|
|
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
|
const string bad = """
|
|
{
|
|
"schemaVersion": 1,
|
|
"encounters": [
|
|
{
|
|
"id": "prototype_combat_pocket",
|
|
"displayName": "",
|
|
"completionCriteria": { "kind": "defeat_all_targets" },
|
|
"requiredNpcInstanceIds": [
|
|
"prototype_npc_melee",
|
|
"prototype_npc_ranged",
|
|
"prototype_npc_elite"
|
|
],
|
|
"rewardTableId": "prototype_combat_pocket_clear"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
File.WriteAllText(Path.Combine(encountersDir, "bad_encounters.json"), bad, Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("bad_encounters.json", ioe.Message, StringComparison.Ordinal);
|
|
Assert.Contains("Encounter catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenSchemaFileMissing()
|
|
{
|
|
// Arrange
|
|
var (_, encountersDir, _) = CreateTempContentLayout();
|
|
WriteCatalog(encountersDir, ValidPrototypeCatalogJson);
|
|
var missingSchema = Path.Combine(encountersDir, "missing-encounter-def.schema.json");
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(encountersDir, missingSchema));
|
|
// 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_ShouldResolveCatalogsFromDi_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 rewardCatalog = factory.Services.GetRequiredService<RewardTableDefinitionCatalog>();
|
|
Assert.Equal(1, rewardCatalog.DistinctRewardTableCount);
|
|
Assert.True(rewardCatalog.TryGetRewardTable("prototype_combat_pocket_clear", out var rewardTable));
|
|
Assert.NotNull(rewardTable);
|
|
Assert.Equal(10, rewardTable!.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity);
|
|
var encounterCatalog = factory.Services.GetRequiredService<EncounterDefinitionCatalog>();
|
|
Assert.Equal(1, encounterCatalog.DistinctEncounterCount);
|
|
Assert.True(encounterCatalog.TryGetEncounter("prototype_combat_pocket", out var encounter));
|
|
Assert.NotNull(encounter);
|
|
Assert.Equal("prototype_combat_pocket_clear", encounter!.RewardTableId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Host_ShouldFailStartup_WhenRewardTablesDirectoryInvalid()
|
|
{
|
|
// Arrange
|
|
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-reward-tables-" + Guid.NewGuid().ToString("n"));
|
|
Directory.CreateDirectory(badDir);
|
|
try
|
|
{
|
|
// Act
|
|
var ex = Record.Exception(() =>
|
|
{
|
|
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
|
b.UseSetting("Content:RewardTablesDirectory", badDir));
|
|
factory.CreateClient();
|
|
});
|
|
// Assert
|
|
Assert.NotNull(ex);
|
|
Assert.Contains("Reward table catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(badDir))
|
|
Directory.Delete(badDir);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Host_ShouldFailStartup_WhenEncountersDirectoryInvalid()
|
|
{
|
|
// Arrange
|
|
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-encounters-" + Guid.NewGuid().ToString("n"));
|
|
Directory.CreateDirectory(badDir);
|
|
try
|
|
{
|
|
// Act
|
|
var ex = Record.Exception(() =>
|
|
{
|
|
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
|
b.UseSetting("Content:EncountersDirectory", badDir));
|
|
factory.CreateClient();
|
|
});
|
|
// Assert
|
|
Assert.NotNull(ex);
|
|
Assert.Contains("Encounter catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(badDir))
|
|
Directory.Delete(badDir);
|
|
}
|
|
}
|
|
}
|