307 lines
14 KiB
C#
307 lines
14 KiB
C#
using System.Collections.Frozen;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NeonSprawl.Server.Game.Contracts;
|
|
using NeonSprawl.Server.Game.Encounters;
|
|
using NeonSprawl.Server.Game.Factions;
|
|
using NeonSprawl.Server.Game.Quests;
|
|
using NeonSprawl.Server.Game.Skills;
|
|
using NeonSprawl.Server.Tests.Game.Skills;
|
|
using Xunit;
|
|
|
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
|
|
|
public class ContractTemplateCatalogLoaderTests
|
|
{
|
|
private static readonly FrozenSet<string> KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds;
|
|
|
|
private static readonly FrozenSet<string> KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds;
|
|
|
|
private static readonly IReadOnlyDictionary<string, SkillDefRow> RepoSkillDefs = LoadRepoSkillDefs();
|
|
|
|
private const string ValidPrototypeCatalogJson =
|
|
"""
|
|
{
|
|
"schemaVersion": 1,
|
|
"contractTemplates": [
|
|
{
|
|
"id": "prototype_contract_clear_combat_pocket",
|
|
"displayName": "Clear Combat Pocket (Repeat)",
|
|
"zoneDifficultyBand": 1,
|
|
"objectiveKind": "encounter_clear",
|
|
"encounterTemplateId": "prototype_combat_pocket",
|
|
"minFactionStanding": {
|
|
"factionId": "prototype_faction_grid_operators",
|
|
"minStanding": 0
|
|
},
|
|
"completionRewardBundle": {
|
|
"itemGrants": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
|
|
"skillXpGrants": [{ "skillId": "salvage", "amount": 15 }]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
private static IReadOnlyDictionary<string, SkillDefRow> LoadRepoSkillDefs()
|
|
{
|
|
var catalog = SkillDefinitionCatalogLoader.Load(
|
|
SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(),
|
|
SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(),
|
|
NullLogger.Instance);
|
|
return catalog.ById;
|
|
}
|
|
|
|
private static ContractTemplateCatalog LoadCatalog(
|
|
string contractsDir,
|
|
string contractTemplateSchemaPath,
|
|
IReadOnlySet<string>? knownEncounterIds = null,
|
|
IReadOnlySet<string>? knownFactionIds = null,
|
|
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null)
|
|
{
|
|
return ContractTemplateCatalogLoader.Load(
|
|
contractsDir,
|
|
contractTemplateSchemaPath,
|
|
ContractCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(),
|
|
ContractCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(),
|
|
ContractCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(),
|
|
ContractCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(),
|
|
ContractCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(),
|
|
knownEncounterIds ?? KnownEncounterIds,
|
|
knownFactionIds ?? KnownFactionIds,
|
|
skillDefsById ?? RepoSkillDefs,
|
|
NullLogger.Instance);
|
|
}
|
|
|
|
private static (string Root, string ContractsDir, string SchemaPath) CreateTempContentLayout()
|
|
{
|
|
var root = Directory.CreateTempSubdirectory("neon-sprawl-contractcat-");
|
|
var contractsDir = Path.Combine(root.FullName, "content", "contracts");
|
|
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
|
Directory.CreateDirectory(contractsDir);
|
|
Directory.CreateDirectory(schemaDir);
|
|
var schemaPath = Path.Combine(schemaDir, "contract-template.schema.json");
|
|
File.Copy(ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath(), schemaPath, overwrite: true);
|
|
File.Copy(ContractCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
|
|
File.Copy(ContractCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
|
|
File.Copy(ContractCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
|
|
File.Copy(ContractCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true);
|
|
File.Copy(ContractCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true);
|
|
return (root.FullName, contractsDir, schemaPath);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile()
|
|
{
|
|
// Arrange
|
|
var contractsDir = ContractCatalogTestPaths.DiscoverRepoContractsDirectory();
|
|
var schemaPath = ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath();
|
|
// Act
|
|
var catalog = LoadCatalog(contractsDir, schemaPath);
|
|
// Assert
|
|
Assert.Equal(PrototypeE7M4ContractCatalogRules.ExpectedTemplateIds.Count, catalog.DistinctTemplateCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
|
{
|
|
// Arrange
|
|
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
|
// Act
|
|
var catalog = LoadCatalog(contractsDir, schemaPath);
|
|
// Assert
|
|
Assert.Equal(1, catalog.DistinctTemplateCount);
|
|
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
|
Assert.True(catalog.ById.TryGetValue("prototype_contract_clear_combat_pocket", out var row));
|
|
Assert.NotNull(row);
|
|
Assert.Equal("Clear Combat Pocket (Repeat)", row!.DisplayName);
|
|
Assert.Equal(1, row.ZoneDifficultyBand);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenContractTemplatesIsNotArray()
|
|
{
|
|
// Arrange
|
|
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(
|
|
Path.Combine(contractsDir, "bad_contract_templates.json"),
|
|
"""{"schemaVersion": 1, "contractTemplates": "nope"}""",
|
|
Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("bad_contract_templates.json", ioe.Message, StringComparison.Ordinal);
|
|
Assert.Contains("expected top-level 'contractTemplates' array", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
|
|
{
|
|
// Arrange
|
|
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(Path.Combine(contractsDir, "a_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
|
File.WriteAllText(Path.Combine(contractsDir, "b_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("duplicate contract template id", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenRosterGateFails()
|
|
{
|
|
// Arrange
|
|
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
|
var emptyCatalog = """{"schemaVersion": 1, "contractTemplates": []}""";
|
|
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), emptyCatalog, Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("prototype E7M4 expects exactly contract template ids", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenFreezeTableDiverges()
|
|
{
|
|
// Arrange
|
|
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
|
var badDisplay = ValidPrototypeCatalogJson.Replace("Clear Combat Pocket (Repeat)", "Wrong Name", StringComparison.Ordinal);
|
|
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), badDisplay, Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("displayName must be", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenEncounterTemplateIdUnknown()
|
|
{
|
|
// Arrange
|
|
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
|
var emptyEncounters = FrozenSet<string>.Empty;
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath, knownEncounterIds: emptyEncounters));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("encounterTemplateId 'prototype_combat_pocket'", ioe.Message, StringComparison.Ordinal);
|
|
Assert.Contains("is not in the loaded encounter catalog", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryGetCrossRefError_ShouldReturnError_WhenBundleItemIdUnknown()
|
|
{
|
|
// Arrange
|
|
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
|
var badItemRow = prototype with
|
|
{
|
|
CompletionRewardBundle = new QuestRewardBundleRow(
|
|
[new RewardGrantRow("not_a_real_item", 5)],
|
|
prototype.CompletionRewardBundle.SkillXpGrants,
|
|
prototype.CompletionRewardBundle.ReputationGrants),
|
|
};
|
|
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
|
{
|
|
[badItemRow.Id] = badItemRow,
|
|
};
|
|
// Act
|
|
var error = PrototypeE7M4ContractCatalogRules.TryGetCrossRefError(
|
|
rows,
|
|
KnownEncounterIds,
|
|
KnownFactionIds,
|
|
RepoSkillDefs);
|
|
// Assert
|
|
Assert.NotNull(error);
|
|
Assert.Contains("itemId 'not_a_real_item'", error, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenSkillXpMissingMissionRewardAllowlist()
|
|
{
|
|
// Arrange
|
|
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
|
var salvageNoMission = new Dictionary<string, SkillDefRow>(StringComparer.Ordinal)
|
|
{
|
|
["salvage"] = new("salvage", "gather", "Salvage", ["activity"]),
|
|
["refine"] = RepoSkillDefs["refine"],
|
|
["intrusion"] = RepoSkillDefs["intrusion"],
|
|
};
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath, skillDefsById: salvageNoMission));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenItemQuantityExceedsPrototypeBundleLimits()
|
|
{
|
|
// Arrange — qty 11 fails freeze before band-cap gate (same CI tamper path as validate_content.py)
|
|
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
|
var overCap = ValidPrototypeCatalogJson.Replace("\"quantity\": 5", "\"quantity\": 11", StringComparison.Ordinal);
|
|
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), overCap, Encoding.UTF8);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("completionRewardBundle must match E7M4 freeze table", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenSchemaFileMissing()
|
|
{
|
|
// Arrange
|
|
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
|
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
|
File.Delete(schemaPath);
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
|
Assert.Contains(schemaPath, ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryGetBandCapGateError_ShouldReturnError_WhenItemQuantityExceedsBandCap()
|
|
{
|
|
// Arrange
|
|
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
|
var overCapRow = prototype with
|
|
{
|
|
CompletionRewardBundle = new QuestRewardBundleRow(
|
|
[new RewardGrantRow("scrap_metal_bulk", 11)],
|
|
prototype.CompletionRewardBundle.SkillXpGrants,
|
|
prototype.CompletionRewardBundle.ReputationGrants),
|
|
};
|
|
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
|
{
|
|
[overCapRow.Id] = overCapRow,
|
|
};
|
|
// Act
|
|
var error = PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rows);
|
|
// Assert
|
|
Assert.NotNull(error);
|
|
Assert.Contains("quantity 11 exceeds band 1 cap 10", error, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Load_ShouldThrow_WhenMissingContractsDirectory()
|
|
{
|
|
// Arrange
|
|
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-missing-contracts-" + Guid.NewGuid().ToString("N"));
|
|
var schemaPath = ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath();
|
|
// Act
|
|
var ex = Record.Exception(() => LoadCatalog(missingDir, schemaPath));
|
|
// Assert
|
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
|
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
|
|
}
|
|
}
|