NEO-145: add fail-fast contract template catalog load and registry.
Startup loader mirrors validate_content.py E7M4 gates (roster, freeze, cross-ref, band caps); IContractTemplateRegistry resolves templates by id. Bruno health smoke confirms host boot after catalog validation.pull/185/head
parent
215493f63b
commit
5ab5022704
|
|
@ -0,0 +1,25 @@
|
||||||
|
meta {
|
||||||
|
name: GET health (contract template catalog boot NEO-145)
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/health
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-145 loads content/contracts/*_contract_templates.json at startup (fail-fast). No contract HTTP API in this story — use this request to confirm the host started after catalog validation.
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("status 200", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("service identity", function () {
|
||||||
|
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
meta {
|
||||||
|
name: contract-catalog
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||||
|
|
||||||
|
internal static class ContractCatalogTestPaths
|
||||||
|
{
|
||||||
|
internal static string DiscoverRepoContractsDirectory() =>
|
||||||
|
ContractCatalogPathResolution.TryDiscoverContractsDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/contracts from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
|
||||||
|
internal static string DiscoverRepoContractTemplateSchemaPath() =>
|
||||||
|
ContractCatalogPathResolution.ResolveContractTemplateSchemaPath(
|
||||||
|
DiscoverRepoContractsDirectory(),
|
||||||
|
configuredSchemaPath: null,
|
||||||
|
contentRootPath: string.Empty);
|
||||||
|
|
||||||
|
internal static string DiscoverRepoRewardGrantRowSchemaPath() =>
|
||||||
|
ContractCatalogPathResolution.ResolveRewardGrantRowSchemaPath(DiscoverRepoContractsDirectory());
|
||||||
|
|
||||||
|
internal static string DiscoverRepoQuestSkillXpGrantSchemaPath() =>
|
||||||
|
ContractCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(DiscoverRepoContractsDirectory());
|
||||||
|
|
||||||
|
internal static string DiscoverRepoQuestRewardBundleSchemaPath() =>
|
||||||
|
ContractCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoContractsDirectory());
|
||||||
|
|
||||||
|
internal static string DiscoverRepoFactionGateRuleSchemaPath() =>
|
||||||
|
ContractCatalogPathResolution.ResolveFactionGateRuleSchemaPath(DiscoverRepoContractsDirectory());
|
||||||
|
|
||||||
|
internal static string DiscoverRepoReputationGrantRowSchemaPath() =>
|
||||||
|
ContractCatalogPathResolution.ResolveReputationGrantRowSchemaPath(DiscoverRepoContractsDirectory());
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
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 Load_ShouldThrow_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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using NeonSprawl.Server.Tests.Game.Skills;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||||
|
|
||||||
|
public class ContractTemplateRegistryTests
|
||||||
|
{
|
||||||
|
private static ContractTemplateRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, ContractTemplateRow> byId)
|
||||||
|
{
|
||||||
|
var catalog = new ContractTemplateCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
|
||||||
|
return new ContractTemplateRegistry(catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetDefinition_ShouldReturnTrue_WhenIdExists()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||||
|
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[expected.Id] = expected,
|
||||||
|
};
|
||||||
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
// Act
|
||||||
|
var found = registry.TryGetDefinition("prototype_contract_clear_combat_pocket", out var def);
|
||||||
|
// Assert
|
||||||
|
Assert.True(found);
|
||||||
|
Assert.NotNull(def);
|
||||||
|
Assert.Equal("Clear Combat Pocket (Repeat)", def!.DisplayName);
|
||||||
|
Assert.Equal("prototype_combat_pocket", def.EncounterTemplateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetDefinition_ShouldReturnFalse_WhenTemplateIdIsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||||
|
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[expected.Id] = expected,
|
||||||
|
};
|
||||||
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
// Act
|
||||||
|
var found = registry.TryGetDefinition(null, out var def);
|
||||||
|
// Assert
|
||||||
|
Assert.False(found);
|
||||||
|
Assert.Null(def);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||||
|
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[expected.Id] = expected,
|
||||||
|
};
|
||||||
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
// Act
|
||||||
|
var found = registry.TryGetDefinition("not_a_real_template", out var def);
|
||||||
|
// Assert
|
||||||
|
Assert.False(found);
|
||||||
|
Assert.Null(def);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleTemplates()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||||
|
var other = prototype with { Id = "zzz_other_template" };
|
||||||
|
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[other.Id] = other,
|
||||||
|
[prototype.Id] = prototype,
|
||||||
|
};
|
||||||
|
var registry = CreateRegistryFromRows(rows);
|
||||||
|
// Act
|
||||||
|
var list = registry.GetDefinitionsInIdOrder();
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, list.Count);
|
||||||
|
Assert.Equal("prototype_contract_clear_combat_pocket", list[0].Id);
|
||||||
|
Assert.Equal("zzz_other_template", list[1].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Host_ShouldResolveContractCatalogFromDi_WhenStartupSucceeds()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
_ = await client.GetAsync("/health");
|
||||||
|
// Act
|
||||||
|
var registry = factory.Services.GetRequiredService<IContractTemplateRegistry>();
|
||||||
|
var found = registry.TryGetDefinition("prototype_contract_clear_combat_pocket", out var template);
|
||||||
|
// Assert
|
||||||
|
Assert.True(found);
|
||||||
|
Assert.NotNull(template);
|
||||||
|
Assert.Equal("Clear Combat Pocket (Repeat)", template!.DisplayName);
|
||||||
|
Assert.Equal("encounter_clear", template.ObjectiveKind);
|
||||||
|
Assert.Equal("prototype_combat_pocket", template.EncounterTemplateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Time.Testing;
|
using Microsoft.Extensions.Time.Testing;
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.Combat;
|
using NeonSprawl.Server.Game.Combat;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
|
@ -66,6 +67,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
var factionsDir = FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory)
|
var factionsDir = FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var contractsDir = ContractCatalogPathResolution.TryDiscoverContractsDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/contracts from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||||
|
|
@ -77,6 +81,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
builder.UseSetting("Content:EncountersDirectory", encountersDir);
|
builder.UseSetting("Content:EncountersDirectory", encountersDir);
|
||||||
builder.UseSetting("Content:FactionsDirectory", factionsDir);
|
builder.UseSetting("Content:FactionsDirectory", factionsDir);
|
||||||
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
||||||
|
builder.UseSetting("Content:ContractsDirectory", contractsDir);
|
||||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||||
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
|
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
|
||||||
builder.UseSetting("Game:EnableResourceNodeFixtureApi", "true");
|
builder.UseSetting("Game:EnableResourceNodeFixtureApi", "true");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Resolves contract catalog paths for local dev, tests, and container layouts (NEO-145).</summary>
|
||||||
|
public static class ContractCatalogPathResolution
|
||||||
|
{
|
||||||
|
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/contracts</c> directory.</summary>
|
||||||
|
public static string? TryDiscoverContractsDirectory(string startDirectory)
|
||||||
|
{
|
||||||
|
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir.FullName, "content", "contracts");
|
||||||
|
if (Directory.Exists(candidate))
|
||||||
|
return Path.GetFullPath(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the contracts catalog directory.
|
||||||
|
/// Empty <paramref name="configuredContractsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolveContractsDirectory(string? configuredContractsDirectory, string contentRootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredContractsDirectory))
|
||||||
|
{
|
||||||
|
var discovered = TryDiscoverContractsDirectory(AppContext.BaseDirectory);
|
||||||
|
if (discovered is not null)
|
||||||
|
return discovered;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Content:ContractsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/contracts'). " +
|
||||||
|
"Set Content:ContractsDirectory in configuration or environment (e.g. Content__ContractsDirectory).");
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = configuredContractsDirectory.Trim();
|
||||||
|
if (Path.IsPathRooted(trimmed))
|
||||||
|
return Path.GetFullPath(trimmed);
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a single contract template row (Draft 2020-12).</summary>
|
||||||
|
public static string ResolveContractTemplateSchemaPath(
|
||||||
|
string contractsDirectory,
|
||||||
|
string? configuredSchemaPath,
|
||||||
|
string contentRootPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||||
|
{
|
||||||
|
var trimmed = configuredSchemaPath.Trim();
|
||||||
|
if (Path.IsPathRooted(trimmed))
|
||||||
|
return Path.GetFullPath(trimmed);
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "contract-template.schema.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a reward grant row (shared by quest bundles and contract templates).</summary>
|
||||||
|
public static string ResolveRewardGrantRowSchemaPath(string contractsDirectory) =>
|
||||||
|
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reward-grant-row.schema.json"));
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a quest skill XP grant row.</summary>
|
||||||
|
public static string ResolveQuestSkillXpGrantSchemaPath(string contractsDirectory) =>
|
||||||
|
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json"));
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a completion reward bundle.</summary>
|
||||||
|
public static string ResolveQuestRewardBundleSchemaPath(string contractsDirectory) =>
|
||||||
|
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-reward-bundle.schema.json"));
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a faction gate rule row.</summary>
|
||||||
|
public static string ResolveFactionGateRuleSchemaPath(string contractsDirectory) =>
|
||||||
|
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "faction-gate-rule.schema.json"));
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a reputation grant row.</summary>
|
||||||
|
public static string ResolveReputationGrantRowSchemaPath(string contractsDirectory) =>
|
||||||
|
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reputation-grant-row.schema.json"));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>DI registration for the fail-fast contract template catalog (NEO-145).</summary>
|
||||||
|
public static class ContractCatalogServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ContractTemplateCatalog"/> and <see cref="IContractTemplateRegistry"/> as singletons.</summary>
|
||||||
|
public static IServiceCollection AddContractTemplateCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<ContentPathsOptions>()
|
||||||
|
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton<ContractTemplateCatalog>(sp =>
|
||||||
|
{
|
||||||
|
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||||
|
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||||
|
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
|
||||||
|
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
|
||||||
|
var factionCatalog = sp.GetRequiredService<FactionDefinitionCatalog>();
|
||||||
|
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("NeonSprawl.Server.Game.Contracts.ContractCatalog");
|
||||||
|
|
||||||
|
var contractsDir = ContractCatalogPathResolution.ResolveContractsDirectory(
|
||||||
|
opts.ContractsDirectory,
|
||||||
|
hostEnv.ContentRootPath);
|
||||||
|
var contractTemplateSchemaPath = ContractCatalogPathResolution.ResolveContractTemplateSchemaPath(
|
||||||
|
contractsDir,
|
||||||
|
opts.ContractTemplateSchemaPath,
|
||||||
|
hostEnv.ContentRootPath);
|
||||||
|
var rewardGrantRowSchemaPath = ContractCatalogPathResolution.ResolveRewardGrantRowSchemaPath(contractsDir);
|
||||||
|
var questSkillXpGrantSchemaPath = ContractCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(contractsDir);
|
||||||
|
var questRewardBundleSchemaPath = ContractCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(contractsDir);
|
||||||
|
var factionGateRuleSchemaPath = ContractCatalogPathResolution.ResolveFactionGateRuleSchemaPath(contractsDir);
|
||||||
|
var reputationGrantRowSchemaPath = ContractCatalogPathResolution.ResolveReputationGrantRowSchemaPath(contractsDir);
|
||||||
|
|
||||||
|
var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var knownFactionIds = factionCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
return ContractTemplateCatalogLoader.Load(
|
||||||
|
contractsDir,
|
||||||
|
contractTemplateSchemaPath,
|
||||||
|
rewardGrantRowSchemaPath,
|
||||||
|
questSkillXpGrantSchemaPath,
|
||||||
|
questRewardBundleSchemaPath,
|
||||||
|
factionGateRuleSchemaPath,
|
||||||
|
reputationGrantRowSchemaPath,
|
||||||
|
knownEncounterIds,
|
||||||
|
knownFactionIds,
|
||||||
|
skillCatalog.ById,
|
||||||
|
logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddSingleton<IContractTemplateRegistry>(sp =>
|
||||||
|
new ContractTemplateRegistry(sp.GetRequiredService<ContractTemplateCatalog>()));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>In-memory contract template catalog loaded at startup (NEO-145). Game code should prefer <see cref="IContractTemplateRegistry"/> for lookups.</summary>
|
||||||
|
public sealed class ContractTemplateCatalog
|
||||||
|
{
|
||||||
|
public ContractTemplateCatalog(
|
||||||
|
string contractsDirectory,
|
||||||
|
IReadOnlyDictionary<string, ContractTemplateRow> byId,
|
||||||
|
int catalogJsonFileCount)
|
||||||
|
{
|
||||||
|
ContractsDirectory = contractsDirectory;
|
||||||
|
ById = new ReadOnlyDictionary<string, ContractTemplateRow>(new Dictionary<string, ContractTemplateRow>(byId, StringComparer.Ordinal));
|
||||||
|
CatalogJsonFileCount = catalogJsonFileCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Absolute path to the directory that was enumerated for <c>*_contract_templates.json</c> catalogs.</summary>
|
||||||
|
public string ContractsDirectory { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, ContractTemplateRow> ById { get; }
|
||||||
|
|
||||||
|
public int DistinctTemplateCount => ById.Count;
|
||||||
|
|
||||||
|
/// <summary>Number of <c>*_contract_templates.json</c> files under <see cref="ContractsDirectory"/>.</summary>
|
||||||
|
public int CatalogJsonFileCount { get; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Json.Schema;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Loads and validates <c>content/contracts/*_contract_templates.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-145).</summary>
|
||||||
|
public static class ContractTemplateCatalogLoader
|
||||||
|
{
|
||||||
|
private static JsonSchema? _contractTemplateSchema;
|
||||||
|
|
||||||
|
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||||
|
public static ContractTemplateCatalog Load(
|
||||||
|
string contractsDirectory,
|
||||||
|
string contractTemplateSchemaPath,
|
||||||
|
string rewardGrantRowSchemaPath,
|
||||||
|
string questSkillXpGrantSchemaPath,
|
||||||
|
string questRewardBundleSchemaPath,
|
||||||
|
string factionGateRuleSchemaPath,
|
||||||
|
string reputationGrantRowSchemaPath,
|
||||||
|
IReadOnlySet<string> knownEncounterIds,
|
||||||
|
IReadOnlySet<string> knownFactionIds,
|
||||||
|
IReadOnlyDictionary<string, SkillDefRow> skillDefsById,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
contractsDirectory = Path.GetFullPath(contractsDirectory);
|
||||||
|
contractTemplateSchemaPath = Path.GetFullPath(contractTemplateSchemaPath);
|
||||||
|
rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath);
|
||||||
|
questSkillXpGrantSchemaPath = Path.GetFullPath(questSkillXpGrantSchemaPath);
|
||||||
|
questRewardBundleSchemaPath = Path.GetFullPath(questRewardBundleSchemaPath);
|
||||||
|
factionGateRuleSchemaPath = Path.GetFullPath(factionGateRuleSchemaPath);
|
||||||
|
reputationGrantRowSchemaPath = Path.GetFullPath(reputationGrantRowSchemaPath);
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
if (!File.Exists(contractTemplateSchemaPath))
|
||||||
|
errors.Add($"error: missing schema file {contractTemplateSchemaPath}");
|
||||||
|
|
||||||
|
if (!File.Exists(rewardGrantRowSchemaPath))
|
||||||
|
errors.Add($"error: missing schema file {rewardGrantRowSchemaPath}");
|
||||||
|
|
||||||
|
if (!File.Exists(questSkillXpGrantSchemaPath))
|
||||||
|
errors.Add($"error: missing schema file {questSkillXpGrantSchemaPath}");
|
||||||
|
|
||||||
|
if (!File.Exists(questRewardBundleSchemaPath))
|
||||||
|
errors.Add($"error: missing schema file {questRewardBundleSchemaPath}");
|
||||||
|
|
||||||
|
if (!File.Exists(factionGateRuleSchemaPath))
|
||||||
|
errors.Add($"error: missing schema file {factionGateRuleSchemaPath}");
|
||||||
|
|
||||||
|
if (!File.Exists(reputationGrantRowSchemaPath))
|
||||||
|
errors.Add($"error: missing schema file {reputationGrantRowSchemaPath}");
|
||||||
|
|
||||||
|
if (!Directory.Exists(contractsDirectory))
|
||||||
|
errors.Add($"error: missing directory {contractsDirectory}");
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var jsonFiles = Directory.GetFiles(contractsDirectory, "*_contract_templates.json", SearchOption.TopDirectoryOnly)
|
||||||
|
.OrderBy(p => p, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (jsonFiles.Length == 0)
|
||||||
|
errors.Add($"error: no *_contract_templates.json files under {contractsDirectory}");
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
EnsureContractSchemasLoaded(
|
||||||
|
contractTemplateSchemaPath,
|
||||||
|
rewardGrantRowSchemaPath,
|
||||||
|
questSkillXpGrantSchemaPath,
|
||||||
|
questRewardBundleSchemaPath,
|
||||||
|
factionGateRuleSchemaPath,
|
||||||
|
reputationGrantRowSchemaPath);
|
||||||
|
var templateSchema = _contractTemplateSchema!;
|
||||||
|
|
||||||
|
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||||
|
|
||||||
|
var templateIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var path in jsonFiles)
|
||||||
|
{
|
||||||
|
JsonNode? root;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = JsonNode.Parse(File.ReadAllText(path));
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root is not JsonObject rootObj)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: expected JSON object at root");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaVersionNode = rootObj["schemaVersion"];
|
||||||
|
if (schemaVersionNode is not JsonValue schemaVersionValue ||
|
||||||
|
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
|
||||||
|
schemaVersion != 1)
|
||||||
|
{
|
||||||
|
var got = schemaVersionNode?.ToJsonString() ?? "null";
|
||||||
|
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesNode = rootObj["contractTemplates"];
|
||||||
|
if (templatesNode is not JsonArray templatesArray)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: expected top-level 'contractTemplates' array");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < templatesArray.Count; i++)
|
||||||
|
{
|
||||||
|
var item = templatesArray[i];
|
||||||
|
if (item is not JsonObject rowObj)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: contractTemplates[{i}] must be an object");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var eval = templateSchema.Evaluate(rowObj, evalOptions);
|
||||||
|
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||||
|
if (!eval.IsValid)
|
||||||
|
{
|
||||||
|
if (schemaMsgs.Count == 0)
|
||||||
|
schemaMsgs.Add($"error: {path} contractTemplates[{i}] (root): schema validation failed");
|
||||||
|
|
||||||
|
errors.AddRange(schemaMsgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||||
|
if (tid is not null && eval.IsValid)
|
||||||
|
{
|
||||||
|
if (templateIdToSourceFile.TryGetValue(tid, out var prevPath))
|
||||||
|
{
|
||||||
|
errors.Add($"error: duplicate contract template id '{tid}' in {prevPath} and {path}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
templateIdToSourceFile[tid] = path;
|
||||||
|
rows[tid] = ParseRow(rowObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var roster = PrototypeE7M4ContractCatalogRules.TryGetRosterGateError(templateIdToSourceFile);
|
||||||
|
if (roster is not null)
|
||||||
|
{
|
||||||
|
errors.Add(roster);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var freeze = PrototypeE7M4ContractCatalogRules.TryGetFreezeGateError(rows);
|
||||||
|
if (freeze is not null)
|
||||||
|
{
|
||||||
|
errors.Add(freeze);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var crossRef = PrototypeE7M4ContractCatalogRules.TryGetCrossRefError(
|
||||||
|
rows,
|
||||||
|
knownEncounterIds,
|
||||||
|
knownFactionIds,
|
||||||
|
skillDefsById);
|
||||||
|
if (crossRef is not null)
|
||||||
|
{
|
||||||
|
errors.Add(crossRef);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bandCap = PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rows);
|
||||||
|
if (bandCap is not null)
|
||||||
|
{
|
||||||
|
errors.Add(bandCap);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logger.IsEnabled(LogLevel.Information))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Loaded contract template catalog from {ContractsDirectory}: {TemplateCount} template(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||||
|
contractsDirectory,
|
||||||
|
rows.Count,
|
||||||
|
jsonFiles.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ContractTemplateCatalog(contractsDirectory, rows, jsonFiles.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContractTemplateRow ParseRow(JsonObject rowObj)
|
||||||
|
{
|
||||||
|
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||||
|
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||||
|
var zoneDifficultyBand = (rowObj["zoneDifficultyBand"] as JsonValue)!.GetValue<int>();
|
||||||
|
var objectiveKind = (rowObj["objectiveKind"] as JsonValue)!.GetValue<string>();
|
||||||
|
var encounterTemplateId = (rowObj["encounterTemplateId"] as JsonValue)!.GetValue<string>();
|
||||||
|
var minStandingObj = rowObj["minFactionStanding"] as JsonObject;
|
||||||
|
var factionId = (minStandingObj!["factionId"] as JsonValue)!.GetValue<string>();
|
||||||
|
var minStanding = (minStandingObj["minStanding"] as JsonValue)!.GetValue<int>();
|
||||||
|
var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject)!;
|
||||||
|
return new ContractTemplateRow(
|
||||||
|
id,
|
||||||
|
displayName,
|
||||||
|
zoneDifficultyBand,
|
||||||
|
objectiveKind,
|
||||||
|
encounterTemplateId,
|
||||||
|
new FactionGateRuleRow(factionId, minStanding),
|
||||||
|
completionRewardBundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj)
|
||||||
|
{
|
||||||
|
if (bundleObj is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray);
|
||||||
|
var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray);
|
||||||
|
var reputationGrants = ParseReputationGrantRows(bundleObj["reputationGrants"] as JsonArray);
|
||||||
|
return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ReputationGrantRow> ParseReputationGrantRows(JsonArray? array)
|
||||||
|
{
|
||||||
|
if (array is null || array.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var rows = new List<ReputationGrantRow>(array.Count);
|
||||||
|
foreach (var node in array)
|
||||||
|
{
|
||||||
|
if (node is not JsonObject grantObj)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var factionId = (grantObj["factionId"] as JsonValue)!.GetValue<string>();
|
||||||
|
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
|
||||||
|
rows.Add(new ReputationGrantRow(factionId, amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<RewardGrantRow> ParseItemGrantRows(JsonArray? array)
|
||||||
|
{
|
||||||
|
if (array is null || array.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var rows = new List<RewardGrantRow>(array.Count);
|
||||||
|
foreach (var node in array)
|
||||||
|
{
|
||||||
|
if (node is not JsonObject grantObj)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var itemId = (grantObj["itemId"] as JsonValue)!.GetValue<string>();
|
||||||
|
var quantity = (grantObj["quantity"] as JsonValue)!.GetValue<int>();
|
||||||
|
rows.Add(new RewardGrantRow(itemId, quantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<QuestSkillXpGrantRow> ParseSkillXpGrantRows(JsonArray? array)
|
||||||
|
{
|
||||||
|
if (array is null || array.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var rows = new List<QuestSkillXpGrantRow>(array.Count);
|
||||||
|
foreach (var node in array)
|
||||||
|
{
|
||||||
|
if (node is not JsonObject grantObj)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var skillId = (grantObj["skillId"] as JsonValue)!.GetValue<string>();
|
||||||
|
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
|
||||||
|
rows.Add(new QuestSkillXpGrantRow(skillId, amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||||
|
{
|
||||||
|
var sink = new List<string>();
|
||||||
|
AppendSchemaMessages(eval, filePath, index, sink);
|
||||||
|
return sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
|
||||||
|
{
|
||||||
|
if (r.HasDetails)
|
||||||
|
{
|
||||||
|
foreach (var d in r.Details!)
|
||||||
|
AppendSchemaMessages(d, filePath, index, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!r.HasErrors)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var kv in r.Errors!)
|
||||||
|
{
|
||||||
|
var loc = r.InstanceLocation?.ToString();
|
||||||
|
if (string.IsNullOrEmpty(loc) || loc == "#")
|
||||||
|
loc = "(root)";
|
||||||
|
|
||||||
|
sink.Add($"error: {filePath} contractTemplates[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registers bundle + gate schemas in <see cref="SchemaRegistry.Global"/> once per process (NEO-145).</summary>
|
||||||
|
private static void EnsureContractSchemasLoaded(
|
||||||
|
string contractTemplateSchemaPath,
|
||||||
|
string rewardGrantRowSchemaPath,
|
||||||
|
string questSkillXpGrantSchemaPath,
|
||||||
|
string questRewardBundleSchemaPath,
|
||||||
|
string factionGateRuleSchemaPath,
|
||||||
|
string reputationGrantRowSchemaPath)
|
||||||
|
{
|
||||||
|
if (_contractTemplateSchema is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CatalogSchemaRegistry.GetOrRegisterFromFile(factionGateRuleSchemaPath);
|
||||||
|
CatalogSchemaRegistry.GetOrRegisterFromFile(reputationGrantRowSchemaPath);
|
||||||
|
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
|
||||||
|
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
|
||||||
|
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);
|
||||||
|
_contractTemplateSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(contractTemplateSchemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ThrowIfAny(List<string> errors)
|
||||||
|
{
|
||||||
|
if (errors.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("Contract template catalog validation failed:");
|
||||||
|
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||||
|
sb.AppendLine(e);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Adapter over <see cref="ContractTemplateCatalog"/> (NEO-145).</summary>
|
||||||
|
public sealed class ContractTemplateRegistry(ContractTemplateCatalog catalog) : IContractTemplateRegistry
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryGetDefinition(string? templateId, [NotNullWhen(true)] out ContractTemplateRow? definition)
|
||||||
|
{
|
||||||
|
if (templateId is null)
|
||||||
|
{
|
||||||
|
definition = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalog.ById.TryGetValue(templateId, out var row))
|
||||||
|
{
|
||||||
|
definition = row;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
definition = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<ContractTemplateRow> GetDefinitionsInIdOrder()
|
||||||
|
{
|
||||||
|
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||||
|
var list = new List<ContractTemplateRow>(ids.Length);
|
||||||
|
foreach (var id in ids)
|
||||||
|
list.Add(catalog.ById[id]);
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Load-time contract template row (NEO-145).</summary>
|
||||||
|
public sealed record ContractTemplateRow(
|
||||||
|
string Id,
|
||||||
|
string DisplayName,
|
||||||
|
int ZoneDifficultyBand,
|
||||||
|
string ObjectiveKind,
|
||||||
|
string EncounterTemplateId,
|
||||||
|
FactionGateRuleRow MinFactionStanding,
|
||||||
|
QuestRewardBundleRow CompletionRewardBundle);
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only access to validated <see cref="ContractTemplateRow"/> entries loaded at startup (<see cref="ContractTemplateCatalog"/>).
|
||||||
|
/// </summary>
|
||||||
|
public interface IContractTemplateRegistry
|
||||||
|
{
|
||||||
|
/// <summary>Attempts to resolve a contract template by stable <c>id</c>. Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
|
||||||
|
bool TryGetDefinition(string? templateId, [NotNullWhen(true)] out ContractTemplateRow? definition);
|
||||||
|
|
||||||
|
/// <summary>Every loaded definition, ordered by <see cref="ContractTemplateRow.Id"/> (ordinal).</summary>
|
||||||
|
IReadOnlyList<ContractTemplateRow> GetDefinitionsInIdOrder();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prototype E7M4 contract template catalog gates (NEO-145), mirrored from
|
||||||
|
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_*</c> and <c>_prototype_e7m4_*</c> gate functions.
|
||||||
|
/// </summary>
|
||||||
|
public static class PrototypeE7M4ContractCatalogRules
|
||||||
|
{
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_TEMPLATE_IDS</c>.</summary>
|
||||||
|
public static readonly FrozenSet<string> ExpectedTemplateIds = FrozenSet.ToFrozenSet(
|
||||||
|
["prototype_contract_clear_combat_pocket"],
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_BAND_CAPS</c>; NEO-147 ports to issue-time validation.</summary>
|
||||||
|
public static readonly FrozenDictionary<int, BandCapPolicy> BandCapsByDifficultyBand =
|
||||||
|
new Dictionary<int, BandCapPolicy>
|
||||||
|
{
|
||||||
|
[1] = new BandCapPolicy(MaxItemQuantity: 10, MaxSkillXpAmount: 25, MaxReputationAmount: 10),
|
||||||
|
}.ToFrozenDictionary();
|
||||||
|
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_TEMPLATE_FREEZE</c>.</summary>
|
||||||
|
public static readonly FrozenDictionary<string, ContractTemplateRow> ExpectedTemplateFreeze =
|
||||||
|
new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["prototype_contract_clear_combat_pocket"] = new(
|
||||||
|
"prototype_contract_clear_combat_pocket",
|
||||||
|
"Clear Combat Pocket (Repeat)",
|
||||||
|
1,
|
||||||
|
"encounter_clear",
|
||||||
|
"prototype_combat_pocket",
|
||||||
|
new FactionGateRuleRow("prototype_faction_grid_operators", 0),
|
||||||
|
new QuestRewardBundleRow(
|
||||||
|
[new RewardGrantRow("scrap_metal_bulk", 5)],
|
||||||
|
[new QuestSkillXpGrantRow("salvage", 15)],
|
||||||
|
[])),
|
||||||
|
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error if the prototype template roster fails, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetRosterGateError(IReadOnlyDictionary<string, string> templateIdToSourceFile)
|
||||||
|
{
|
||||||
|
var ids = templateIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||||
|
if (!ids.SetEquals(ExpectedTemplateIds))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
"error: prototype E7M4 expects exactly contract template ids " +
|
||||||
|
$"[{string.Join(", ", ExpectedTemplateIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||||
|
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error if template rows diverge from the E7M4 freeze table, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetFreezeGateError(IReadOnlyDictionary<string, ContractTemplateRow> rowsById)
|
||||||
|
{
|
||||||
|
foreach (var (tid, expected) in ExpectedTemplateFreeze)
|
||||||
|
{
|
||||||
|
if (!rowsById.TryGetValue(tid, out var actual))
|
||||||
|
return $"error: missing contract template '{tid}'";
|
||||||
|
|
||||||
|
if (actual.DisplayName != expected.DisplayName)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' displayName must be '{expected.DisplayName}', got '{actual.DisplayName}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actual.ZoneDifficultyBand != expected.ZoneDifficultyBand)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' zoneDifficultyBand must be {expected.ZoneDifficultyBand}, got {actual.ZoneDifficultyBand}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actual.ObjectiveKind != expected.ObjectiveKind)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' objectiveKind must be '{expected.ObjectiveKind}', got '{actual.ObjectiveKind}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actual.EncounterTemplateId != expected.EncounterTemplateId)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' encounterTemplateId must be '{expected.EncounterTemplateId}', got '{actual.EncounterTemplateId}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actual.MinFactionStanding.FactionId != expected.MinFactionStanding.FactionId ||
|
||||||
|
actual.MinFactionStanding.MinStanding != expected.MinFactionStanding.MinStanding)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' minFactionStanding must be {{ factionId: '{expected.MinFactionStanding.FactionId}', minStanding: {expected.MinFactionStanding.MinStanding} }}, " +
|
||||||
|
$"got {{ factionId: '{actual.MinFactionStanding.FactionId}', minStanding: {actual.MinFactionStanding.MinStanding} }}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var actualBundle = NormalizeBundle(actual.CompletionRewardBundle);
|
||||||
|
var expectedBundle = NormalizeBundle(expected.CompletionRewardBundle);
|
||||||
|
if (!BundlesEqual(actualBundle, expectedBundle))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' completionRewardBundle must match E7M4 freeze table " +
|
||||||
|
$"(expected {FormatBundle(expectedBundle)}, got {FormatBundle(actualBundle)})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error when cross-refs fail, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetCrossRefError(
|
||||||
|
IReadOnlyDictionary<string, ContractTemplateRow> rowsById,
|
||||||
|
IReadOnlySet<string> knownEncounterIds,
|
||||||
|
IReadOnlySet<string> knownFactionIds,
|
||||||
|
IReadOnlyDictionary<string, SkillDefRow> skillDefsById)
|
||||||
|
{
|
||||||
|
foreach (var tid in rowsById.Keys.Order(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var row = rowsById[tid];
|
||||||
|
if (!knownEncounterIds.Contains(row.EncounterTemplateId))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' encounterTemplateId '{row.EncounterTemplateId}' " +
|
||||||
|
"is not in the loaded encounter catalog";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!knownFactionIds.Contains(row.MinFactionStanding.FactionId))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' minFactionStanding.factionId " +
|
||||||
|
$"'{row.MinFactionStanding.FactionId}' is not in the frozen prototype faction catalog";
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundle = row.CompletionRewardBundle;
|
||||||
|
for (var gi = 0; gi < bundle.ItemGrants.Count; gi++)
|
||||||
|
{
|
||||||
|
var grant = bundle.ItemGrants[gi];
|
||||||
|
if (!PrototypeSlice1ItemCatalogRules.ExpectedItemIds.Contains(grant.ItemId))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' completionRewardBundle.itemGrants[{gi}]: itemId " +
|
||||||
|
$"'{grant.ItemId}' is not in the frozen prototype item catalog";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++)
|
||||||
|
{
|
||||||
|
var grant = bundle.SkillXpGrants[gi];
|
||||||
|
if (!PrototypeSlice1SkillCatalogRules.ExpectedSkillIds.Contains(grant.SkillId))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
|
||||||
|
$"'{grant.SkillId}' is not in the frozen prototype skill catalog";
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDefsById.TryGetValue(grant.SkillId, out var skill);
|
||||||
|
var allowedKinds = skill?.AllowedXpSourceKinds ?? [];
|
||||||
|
if (!allowedKinds.Contains(PrototypeE7M2QuestCatalogRules.MissionRewardSourceKind, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var kindsDisplay = allowedKinds.Count == 0
|
||||||
|
? "[]"
|
||||||
|
: $"[{string.Join(", ", allowedKinds.Select(k => $"'{k}'"))}]";
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
|
||||||
|
$"'{grant.SkillId}' must allow sourceKind '{PrototypeE7M2QuestCatalogRules.MissionRewardSourceKind}' " +
|
||||||
|
$"in allowedXpSourceKinds (got {kindsDisplay})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var gi = 0; gi < bundle.ReputationGrants.Count; gi++)
|
||||||
|
{
|
||||||
|
var grant = bundle.ReputationGrants[gi];
|
||||||
|
if (!knownFactionIds.Contains(grant.FactionId))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' completionRewardBundle.reputationGrants[{gi}]: factionId " +
|
||||||
|
$"'{grant.FactionId}' is not in the frozen prototype faction catalog";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error if a template bundle exceeds band caps, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetBandCapGateError(IReadOnlyDictionary<string, ContractTemplateRow> rowsById)
|
||||||
|
{
|
||||||
|
foreach (var tid in rowsById.Keys.Order(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var row = rowsById[tid];
|
||||||
|
if (!BandCapsByDifficultyBand.TryGetValue(row.ZoneDifficultyBand, out var caps))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' zoneDifficultyBand {row.ZoneDifficultyBand} has no " +
|
||||||
|
"prototype economy cap policy";
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundle = row.CompletionRewardBundle;
|
||||||
|
for (var gi = 0; gi < bundle.ItemGrants.Count; gi++)
|
||||||
|
{
|
||||||
|
var grant = bundle.ItemGrants[gi];
|
||||||
|
if (grant.Quantity > caps.MaxItemQuantity)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' completionRewardBundle.itemGrants[{gi}] " +
|
||||||
|
$"quantity {grant.Quantity} exceeds band {row.ZoneDifficultyBand} cap {caps.MaxItemQuantity}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++)
|
||||||
|
{
|
||||||
|
var grant = bundle.SkillXpGrants[gi];
|
||||||
|
if (grant.Amount > caps.MaxSkillXpAmount)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}] " +
|
||||||
|
$"amount {grant.Amount} exceeds band {row.ZoneDifficultyBand} cap {caps.MaxSkillXpAmount}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var gi = 0; gi < bundle.ReputationGrants.Count; gi++)
|
||||||
|
{
|
||||||
|
var grant = bundle.ReputationGrants[gi];
|
||||||
|
if (Math.Abs(grant.Amount) > caps.MaxReputationAmount)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: contract template '{tid}' completionRewardBundle.reputationGrants[{gi}] " +
|
||||||
|
$"amount {grant.Amount} exceeds band {row.ZoneDifficultyBand} rep cap {caps.MaxReputationAmount}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) =>
|
||||||
|
new(
|
||||||
|
bundle.ItemGrants
|
||||||
|
.OrderBy(g => g.ItemId, StringComparer.Ordinal)
|
||||||
|
.ThenBy(g => g.Quantity)
|
||||||
|
.ToArray(),
|
||||||
|
bundle.SkillXpGrants
|
||||||
|
.OrderBy(g => g.SkillId, StringComparer.Ordinal)
|
||||||
|
.ThenBy(g => g.Amount)
|
||||||
|
.ToArray(),
|
||||||
|
bundle.ReputationGrants
|
||||||
|
.OrderBy(g => g.FactionId, StringComparer.Ordinal)
|
||||||
|
.ThenBy(g => g.Amount)
|
||||||
|
.ToArray());
|
||||||
|
|
||||||
|
private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right)
|
||||||
|
{
|
||||||
|
if (left.ItemGrants.Length != right.ItemGrants.Length ||
|
||||||
|
left.SkillXpGrants.Length != right.SkillXpGrants.Length ||
|
||||||
|
left.ReputationGrants.Length != right.ReputationGrants.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < left.ItemGrants.Length; i++)
|
||||||
|
{
|
||||||
|
if (left.ItemGrants[i].ItemId != right.ItemGrants[i].ItemId ||
|
||||||
|
left.ItemGrants[i].Quantity != right.ItemGrants[i].Quantity)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < left.SkillXpGrants.Length; i++)
|
||||||
|
{
|
||||||
|
if (left.SkillXpGrants[i].SkillId != right.SkillXpGrants[i].SkillId ||
|
||||||
|
left.SkillXpGrants[i].Amount != right.SkillXpGrants[i].Amount)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < left.ReputationGrants.Length; i++)
|
||||||
|
{
|
||||||
|
if (left.ReputationGrants[i].FactionId != right.ReputationGrants[i].FactionId ||
|
||||||
|
left.ReputationGrants[i].Amount != right.ReputationGrants[i].Amount)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatBundle(NormalizedBundle bundle)
|
||||||
|
{
|
||||||
|
var itemGrants = string.Join(
|
||||||
|
", ",
|
||||||
|
bundle.ItemGrants.Select(g => $"{{ itemId: '{g.ItemId}', quantity: {g.Quantity} }}"));
|
||||||
|
var skillXpGrants = string.Join(
|
||||||
|
", ",
|
||||||
|
bundle.SkillXpGrants.Select(g => $"{{ skillId: '{g.SkillId}', amount: {g.Amount} }}"));
|
||||||
|
var reputationGrants = string.Join(
|
||||||
|
", ",
|
||||||
|
bundle.ReputationGrants.Select(g => $"{{ factionId: '{g.FactionId}', amount: {g.Amount} }}"));
|
||||||
|
return
|
||||||
|
$"{{ itemGrants: [{itemGrants}], skillXpGrants: [{skillXpGrants}], reputationGrants: [{reputationGrants}] }}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record NormalizedBundle(
|
||||||
|
RewardGrantRow[] ItemGrants,
|
||||||
|
QuestSkillXpGrantRow[] SkillXpGrants,
|
||||||
|
ReputationGrantRow[] ReputationGrants);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Prototype economy caps keyed by zone difficulty band (NEO-145).</summary>
|
||||||
|
public sealed record BandCapPolicy(int MaxItemQuantity, int MaxSkillXpAmount, int MaxReputationAmount);
|
||||||
|
|
@ -166,4 +166,16 @@ public sealed class ContentPathsOptions
|
||||||
/// When unset, resolved as <c>{parent of factions directory}/schemas/faction-def.schema.json</c>.
|
/// When unset, resolved as <c>{parent of factions directory}/schemas/faction-def.schema.json</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? FactionDefSchemaPath { get; set; }
|
public string? FactionDefSchemaPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||||
|
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/contracts</c> directory.
|
||||||
|
/// </summary>
|
||||||
|
public string? ContractsDirectory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override for <c>contract-template.schema.json</c>.
|
||||||
|
/// When unset, resolved as <c>{parent of contracts directory}/schemas/contract-template.schema.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? ContractTemplateSchemaPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.Combat;
|
using NeonSprawl.Server.Game.Combat;
|
||||||
|
using NeonSprawl.Server.Game.Contracts;
|
||||||
using NeonSprawl.Server.Game.Crafting;
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
using NeonSprawl.Server.Game.Factions;
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
|
@ -32,6 +33,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
|
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
|
||||||
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
|
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
|
||||||
|
builder.Services.AddContractTemplateCatalog(builder.Configuration);
|
||||||
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
|
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
|
||||||
builder.Services.AddRewardDeliveryStore();
|
builder.Services.AddRewardDeliveryStore();
|
||||||
builder.Services.AddFactionStandingStores(builder.Configuration);
|
builder.Services.AddFactionStandingStores(builder.Configuration);
|
||||||
|
|
@ -51,6 +53,7 @@ _ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<EncounterDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<EncounterDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<QuestDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<QuestDefinitionCatalog>();
|
||||||
|
_ = app.Services.GetRequiredService<ContractTemplateCatalog>();
|
||||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,21 @@ Bundle-related schemas (**`quest-reward-bundle.schema.json`**, **`quest-skill-xp
|
||||||
|
|
||||||
On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes.
|
On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes.
|
||||||
|
|
||||||
|
## Contract template catalog (`content/contracts`, NEO-145)
|
||||||
|
|
||||||
|
On startup the host loads every **`*_contract_templates.json`** under the contracts directory **after** the item, recipe, encounter, skill, faction, and quest catalogs, validates each row against **`content/schemas/contract-template.schema.json`** (with **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**, **`faction-gate-rule.schema.json`**, and **`reputation-grant-row.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate template `id`** values across files, cross-checks **`encounterTemplateId`** against the loaded encounter catalog and bundle item/skill/faction refs against frozen prototype catalogs, enforces the **prototype E7M4** one-template roster and freeze table (including empty **`reputationGrants`**), and enforces **band-1 economy caps** (item qty ≤ **10**, skill XP ≤ **25**, rep ≤ **10** per grant row — same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||||||
|
|
||||||
|
| Config | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| **`Content:ContractsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/contracts`**. |
|
||||||
|
| **`Content:ContractTemplateSchemaPath`** | Optional override for **`contract-template.schema.json`**. When unset, **`{parent of contracts directory}/schemas/contract-template.schema.json`**. |
|
||||||
|
|
||||||
|
Bundle- and gate-related schemas resolve as siblings under **`{parent of contracts directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those files next to the contract template schema (NEO-145).
|
||||||
|
|
||||||
|
**Docker / CI:** include **`content/contracts`**, upstream catalogs (**`content/encounters`**, **`content/skills`**, **`content/factions`**, **`content/items`**), and contract/bundle/faction schemas in the mounted **`content/`** tree; set **`Content__ContractsDirectory`** when layout differs.
|
||||||
|
|
||||||
|
On success, **Information** logs include the resolved contracts directory path, distinct template count, and catalog file count. Game code should use **`IContractTemplateRegistry`** for lookups (`TryGetDefinition`, `GetDefinitionsInIdOrder`; NEO-145). The catalog singleton remains for fail-fast startup only; do not inject **`ContractTemplateCatalog`** in new game code.
|
||||||
|
|
||||||
## Quest definitions (NEO-115)
|
## Quest definitions (NEO-115)
|
||||||
|
|
||||||
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
|
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue