neon-sprawl/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoad...

302 lines
14 KiB
C#

using System.Collections.Frozen;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Quests;
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();
const string 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);
}
}