NEO-134: fail-fast faction catalog load with E7M3 quest gates

Add Game/Factions loader, registry, and CI-parity prototype rules; extend
quest catalog to parse faction gates/rep grants and cross-check at startup.
pull/173/head
VinPropane 2026-06-15 20:08:46 -04:00
parent edb33514c5
commit 48bca98467
27 changed files with 1309 additions and 13 deletions

View File

@ -0,0 +1,25 @@
meta {
name: GET health (faction catalog boot NEO-134)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-134 loads content/factions/*_factions.json at startup (fail-fast). No faction 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");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: faction-catalog
}

View File

@ -43,9 +43,16 @@ Fail-fast startup load of `content/factions/*_factions.json` with **CI-parity**
## Acceptance criteria checklist
- [ ] Host exits on invalid faction catalog or broken quest faction refs at startup.
- [ ] Registry resolves faction metadata by id.
- [ ] `dotnet test` covers loader gates.
- [x] Host exits on invalid faction catalog or broken quest faction refs at startup.
- [x] Registry resolves faction metadata by id.
- [x] `dotnet test` covers loader gates.
## Implementation reconciliation (shipped)
- **Faction catalog:** `Game/Factions/` loader, catalog, `IFactionDefinitionRegistry`, `PrototypeE7M3FactionCatalogRules`, DI + `Program.cs` load order (after skills, before quests).
- **Quest extensions:** `FactionGateRuleRow`, `ReputationGrantRow`, `PrototypeE7M3QuestFactionRules` (cross-ref, E7M3 bundle freeze, grid-contract shape); quest loader parses faction fields and runs E7M3 gates.
- **Tests:** `FactionDefinitionCatalogLoaderTests`, `FactionDefinitionRegistryTests`, four new quest-loader negative cases; host boot resolves faction + quest catalogs (`746` tests green).
- **Docs:** `server/README.md` faction catalog section; quest catalog paragraph updated for E7M3 gates.
## Technical approach

View File

@ -0,0 +1,17 @@
using NeonSprawl.Server.Game.Factions;
namespace NeonSprawl.Server.Tests.Game.Factions;
internal static class FactionCatalogTestPaths
{
internal static string DiscoverRepoFactionsDirectory() =>
FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
internal static string DiscoverRepoFactionDefSchemaPath() =>
FactionCatalogPathResolution.ResolveFactionDefSchemaPath(
DiscoverRepoFactionsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
}

View File

@ -0,0 +1,191 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public class FactionDefinitionCatalogLoaderTests
{
private const string ValidPrototypeCatalogJson =
"""
{
"schemaVersion": 1,
"factions": [
{
"id": "prototype_faction_grid_operators",
"displayName": "Grid Operators",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
},
{
"id": "prototype_faction_rust_collective",
"displayName": "Rust Collective",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
}
]
}
""";
private static (string Root, string FactionsDir, string SchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-factioncat-");
var factionsDir = Path.Combine(root.FullName, "content", "factions");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(factionsDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "faction-def.schema.json");
File.Copy(FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath(), schemaPath, overwrite: true);
return (root.FullName, factionsDir, schemaPath);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile()
{
// Arrange
var factionsDir = FactionCatalogTestPaths.DiscoverRepoFactionsDirectory();
var schemaPath = FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath();
// Act
var catalog = FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance);
// Assert
Assert.Equal(PrototypeE7M3FactionCatalogRules.ExpectedFactionIds.Count, catalog.DistinctFactionCount);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
// Act
var catalog = FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance);
// Assert
Assert.Equal(2, catalog.DistinctFactionCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.ById.TryGetValue("prototype_faction_grid_operators", out var grid));
Assert.NotNull(grid);
Assert.Equal("Grid Operators", grid!.DisplayName);
Assert.Equal(0, grid.NeutralStanding);
}
[Fact]
public void Load_ShouldThrow_WhenFactionsIsNotArray()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(factionsDir, "bad_factions.json"), """{"schemaVersion": 1, "factions": "nope"}""", Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_factions.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'factions' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var singleFaction = """
{
"schemaVersion": 1,
"factions": [
{
"id": "prototype_faction_grid_operators",
"displayName": "Grid Operators",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
}
]
}
""";
File.WriteAllText(Path.Combine(factionsDir, "a_factions.json"), singleFaction, Encoding.UTF8);
File.WriteAllText(Path.Combine(factionsDir, "b_factions.json"), singleFaction, Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate faction id", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRosterGateFails()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var oneFaction = """
{
"schemaVersion": 1,
"factions": [
{
"id": "prototype_faction_grid_operators",
"displayName": "Grid Operators",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
}
]
}
""";
File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), oneFaction, Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype E7M3 expects exactly faction ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenFreezeTableDiverges()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var badDisplay = ValidPrototypeCatalogJson.Replace("Grid Operators", "Wrong Name", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), badDisplay, Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("must match E7M3 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void TryGetStandingBandGateError_ShouldReturnError_WhenOrderingFails()
{
// Arrange
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
10,
100,
0),
};
// Act
var error = PrototypeE7M3FactionCatalogRules.TryGetStandingBandGateError(rows);
// Assert
Assert.NotNull(error);
Assert.Contains("requires minStanding <= neutralStanding <= maxStanding", error, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaFileMissing()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
File.Delete(schemaPath);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
}
}

View File

@ -0,0 +1,126 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public class FactionDefinitionRegistryTests
{
private static FactionDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, FactionDefRow> byId)
{
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new FactionDefinitionRegistry(catalog);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrue_WhenIdExists()
{
// Arrange
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("prototype_faction_grid_operators", out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal("Grid Operators", def!.DisplayName);
Assert.Equal(-100, def.MinStanding);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenFactionIdIsNull()
{
// Arrange
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
};
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 rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_faction", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleFactions()
{
// Arrange
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_rust_collective"] = new(
"prototype_faction_rust_collective",
"Rust Collective",
-100,
100,
0),
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(2, list.Count);
Assert.Equal("prototype_faction_grid_operators", list[0].Id);
Assert.Equal("prototype_faction_rust_collective", list[1].Id);
}
[Fact]
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var registry = factory.Services.GetRequiredService<IFactionDefinitionRegistry>();
var found = registry.TryGetDefinition("prototype_faction_grid_operators", out var grid);
// Assert
Assert.True(found);
Assert.NotNull(grid);
Assert.Equal("Grid Operators", grid!.DisplayName);
}
}

View File

@ -43,4 +43,7 @@ internal static class QuestCatalogTestPaths
internal static string DiscoverRepoReputationGrantRowSchemaPath() =>
Path.GetFullPath(
Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "reputation-grant-row.schema.json"));
internal static string DiscoverRepoFactionsDirectory() =>
Path.GetFullPath(Path.Combine(DiscoverRepoQuestsDirectory(), "..", "factions"));
}

View File

@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
@ -18,6 +19,8 @@ namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionCatalogLoaderTests
{
private static readonly FrozenSet<string> KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds;
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds;
@ -247,7 +250,8 @@ public class QuestDefinitionCatalogLoaderTests
string defSchemaPath,
string stepSchemaPath,
string objectiveSchemaPath,
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null) =>
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null,
IReadOnlySet<string>? knownFactionIds = null) =>
QuestDefinitionCatalogLoader.Load(
questsDir,
defSchemaPath,
@ -259,6 +263,7 @@ public class QuestDefinitionCatalogLoaderTests
KnownItemIds,
KnownRecipeIds,
KnownEncounterIds,
knownFactionIds ?? KnownFactionIds,
skillDefsById ?? RepoSkillDefs,
NullLogger.Instance);
@ -752,6 +757,88 @@ public class QuestDefinitionCatalogLoaderTests
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenFactionGateReferencesUnknownFaction()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var grid = GetQuestRow(root, "prototype_quest_grid_contract");
var gateRules = grid["factionGateRules"] as JsonArray
?? throw new InvalidOperationException("expected factionGateRules");
(gateRules[0] as JsonObject)!["factionId"] = "unknown_faction_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("factionGateRules[0]", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenReputationGrantReferencesUnknownFaction()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var chain = GetQuestRow(root, "prototype_quest_operator_chain");
var bundle = chain["completionRewardBundle"] as JsonObject
?? throw new InvalidOperationException("expected completionRewardBundle");
var grants = bundle["reputationGrants"] as JsonArray
?? throw new InvalidOperationException("expected reputationGrants");
(grants[0] as JsonObject)!["factionId"] = "unknown_faction_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("reputationGrants[0]", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenE7M3CompletionBundleFreezeDiverges()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var chain = GetQuestRow(root, "prototype_quest_operator_chain");
var bundle = chain["completionRewardBundle"] as JsonObject
?? throw new InvalidOperationException("expected completionRewardBundle");
var grants = bundle["reputationGrants"] as JsonArray
?? throw new InvalidOperationException("expected reputationGrants");
(grants[0] as JsonObject)!["amount"] = 99;
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("completionRewardBundle must match E7M3 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenGridContractGateMinStandingDiverges()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var grid = GetQuestRow(root, "prototype_quest_grid_contract");
var gateRules = grid["factionGateRules"] as JsonArray
?? throw new InvalidOperationException("expected factionGateRules");
(gateRules[0] as JsonObject)!["minStanding"] = 10;
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("factionGateRules must be", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingSchemaFile()
{

View File

@ -8,6 +8,7 @@ using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
@ -62,6 +63,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var factionsDir = FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
@ -71,6 +75,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
builder.UseSetting("Content:EncountersDirectory", encountersDir);
builder.UseSetting("Content:FactionsDirectory", factionsDir);
builder.UseSetting("Content:QuestsDirectory", questsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");

View File

@ -0,0 +1,60 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Resolves faction catalog paths for local dev, tests, and container layouts (NEO-134).</summary>
public static class FactionCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/factions</c> directory.</summary>
public static string? TryDiscoverFactionsDirectory(string startDirectory)
{
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "content", "factions");
if (Directory.Exists(candidate))
return Path.GetFullPath(candidate);
}
return null;
}
/// <summary>
/// Resolves the factions catalog directory.
/// Empty <paramref name="configuredFactionsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
public static string ResolveFactionsDirectory(string? configuredFactionsDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredFactionsDirectory))
{
var discovered = TryDiscoverFactionsDirectory(AppContext.BaseDirectory);
if (discovered is not null)
return discovered;
throw new InvalidOperationException(
"Content:FactionsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/factions'). " +
"Set Content:FactionsDirectory in configuration or environment (e.g. Content__FactionsDirectory).");
}
var trimmed = configuredFactionsDirectory.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
/// <summary>Resolves JSON Schema path for a single faction row (Draft 2020-12).</summary>
public static string ResolveFactionDefSchemaPath(
string factionsDirectory,
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(factionsDirectory, "..", "schemas", "faction-def.schema.json"));
}
}

View File

@ -0,0 +1,40 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>DI registration for the fail-fast faction catalog (NEO-134).</summary>
public static class FactionCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="FactionDefinitionCatalog"/> and <see cref="IFactionDefinitionRegistry"/> as singletons.</summary>
public static IServiceCollection AddFactionDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<FactionDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Factions.FactionCatalog");
var factionsDir = FactionCatalogPathResolution.ResolveFactionsDirectory(
opts.FactionsDirectory,
hostEnv.ContentRootPath);
var schemaPath = FactionCatalogPathResolution.ResolveFactionDefSchemaPath(
factionsDir,
opts.FactionDefSchemaPath,
hostEnv.ContentRootPath);
return FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, logger);
});
services.AddSingleton<IFactionDefinitionRegistry>(sp =>
new FactionDefinitionRegistry(sp.GetRequiredService<FactionDefinitionCatalog>()));
return services;
}
}

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Load-time faction row (NEO-134).</summary>
public sealed record FactionDefRow(
string Id,
string DisplayName,
int MinStanding,
int MaxStanding,
int NeutralStanding);

View File

@ -0,0 +1,27 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>In-memory faction catalog loaded at startup (NEO-134). Game code should prefer <see cref="IFactionDefinitionRegistry"/> for lookups.</summary>
public sealed class FactionDefinitionCatalog
{
public FactionDefinitionCatalog(
string factionsDirectory,
IReadOnlyDictionary<string, FactionDefRow> byId,
int catalogJsonFileCount)
{
FactionsDirectory = factionsDirectory;
ById = new ReadOnlyDictionary<string, FactionDefRow>(new Dictionary<string, FactionDefRow>(byId, StringComparer.Ordinal));
CatalogJsonFileCount = catalogJsonFileCount;
}
/// <summary>Absolute path to the directory that was enumerated for <c>*_factions.json</c> catalogs.</summary>
public string FactionsDirectory { get; }
public IReadOnlyDictionary<string, FactionDefRow> ById { get; }
public int DistinctFactionCount => ById.Count;
/// <summary>Number of <c>*_factions.json</c> files under <see cref="FactionsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; }
}

View File

@ -0,0 +1,198 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Loads and validates <c>content/factions/*_factions.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-134).</summary>
public static class FactionDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static FactionDefinitionCatalog Load(string factionsDirectory, string schemaPath, ILogger logger)
{
factionsDirectory = Path.GetFullPath(factionsDirectory);
schemaPath = Path.GetFullPath(schemaPath);
var errors = new List<string>();
if (!File.Exists(schemaPath))
errors.Add($"error: missing schema file {schemaPath}");
if (!Directory.Exists(factionsDirectory))
errors.Add($"error: missing directory {factionsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var jsonFiles = Directory.GetFiles(factionsDirectory, "*_factions.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
if (jsonFiles.Length == 0)
errors.Add($"error: no *_factions.json files under {factionsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var factionIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
var rows = new Dictionary<string, FactionDefRow>(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 factionsNode = rootObj["factions"];
if (factionsNode is not JsonArray factionsArray)
{
errors.Add($"error: {path}: expected top-level 'factions' array");
continue;
}
for (var i = 0; i < factionsArray.Count; i++)
{
var item = factionsArray[i];
if (item is not JsonObject rowObj)
{
errors.Add($"error: {path}: factions[{i}] must be an object");
continue;
}
var eval = schema.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} factions[{i}] (root): schema validation failed");
errors.AddRange(schemaMsgs);
}
var rowSchemaErrors = schemaMsgs.Count;
var fid = (rowObj["id"] as JsonValue)?.GetValue<string>();
if (fid is not null && rowSchemaErrors == 0)
{
if (factionIdToSourceFile.TryGetValue(fid, out var prevPath))
{
errors.Add($"error: duplicate faction id '{fid}' in {prevPath} and {path}");
continue;
}
factionIdToSourceFile[fid] = path;
rows[fid] = ParseRow(rowObj);
}
}
}
ThrowIfAny(errors);
var roster = PrototypeE7M3FactionCatalogRules.TryGetRosterGateError(factionIdToSourceFile);
if (roster is not null)
{
errors.Add(roster);
ThrowIfAny(errors);
}
var freeze = PrototypeE7M3FactionCatalogRules.TryGetFreezeGateError(rows);
if (freeze is not null)
{
errors.Add(freeze);
ThrowIfAny(errors);
}
var standingBand = PrototypeE7M3FactionCatalogRules.TryGetStandingBandGateError(rows);
if (standingBand is not null)
{
errors.Add(standingBand);
ThrowIfAny(errors);
}
logger.LogInformation(
"Loaded faction catalog from {FactionsDirectory}: {FactionCount} faction(s) across {CatalogFileCount} JSON catalog file(s).",
factionsDirectory,
rows.Count,
jsonFiles.Length);
return new FactionDefinitionCatalog(factionsDirectory, rows, jsonFiles.Length);
}
private static FactionDefRow ParseRow(JsonObject rowObj)
{
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var minStanding = (rowObj["minStanding"] as JsonValue)!.GetValue<int>();
var maxStanding = (rowObj["maxStanding"] as JsonValue)!.GetValue<int>();
var neutralStanding = (rowObj["neutralStanding"] as JsonValue)!.GetValue<int>();
return new FactionDefRow(id, displayName, minStanding, maxStanding, neutralStanding);
}
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} factions[{index}] {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Faction catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

@ -0,0 +1,39 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Adapter over <see cref="FactionDefinitionCatalog"/> (NEO-134).</summary>
public sealed class FactionDefinitionRegistry(FactionDefinitionCatalog catalog) : IFactionDefinitionRegistry
{
/// <inheritdoc />
public bool TryGetDefinition(string? factionId, [NotNullWhen(true)] out FactionDefRow? definition)
{
if (factionId is null)
{
definition = null;
return false;
}
if (catalog.ById.TryGetValue(factionId, out var row))
{
definition = row;
return true;
}
definition = null;
return false;
}
/// <inheritdoc />
public IReadOnlyList<FactionDefRow> GetDefinitionsInIdOrder()
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<FactionDefRow>(ids.Length);
foreach (var id in ids)
{
list.Add(catalog.ById[id]);
}
return list;
}
}

View File

@ -0,0 +1,15 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>
/// Read-only access to validated <see cref="FactionDefRow"/> entries loaded at startup (<see cref="FactionDefinitionCatalog"/>).
/// </summary>
public interface IFactionDefinitionRegistry
{
/// <summary>Attempts to resolve a faction by stable <c>id</c> (see <c>faction-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? factionId, [NotNullWhen(true)] out FactionDefRow? definition);
/// <summary>Every loaded definition, ordered by <see cref="FactionDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<FactionDefRow> GetDefinitionsInIdOrder();
}

View File

@ -0,0 +1,95 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>
/// Prototype E7M3 faction catalog gates (NEO-134), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_FACTION_*</c> and <c>_prototype_e7m3_faction_*</c> gate functions.
/// </summary>
public static class PrototypeE7M3FactionCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_FACTION_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedFactionIds = FrozenSet.ToFrozenSet(
[
"prototype_faction_grid_operators",
"prototype_faction_rust_collective",
],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_FACTION_FREEZE</c>.</summary>
public static readonly FrozenDictionary<string, FactionDefRow> ExpectedFactionFreeze =
new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
["prototype_faction_rust_collective"] = new(
"prototype_faction_rust_collective",
"Rust Collective",
-100,
100,
0),
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the prototype faction roster fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetRosterGateError(IReadOnlyDictionary<string, string> factionIdToSourceFile)
{
var ids = factionIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedFactionIds))
{
return
"error: prototype E7M3 expects exactly faction ids " +
$"[{string.Join(", ", ExpectedFactionIds.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 faction rows diverge from the E7M3 freeze table, otherwise <see langword="null"/>.</summary>
public static string? TryGetFreezeGateError(IReadOnlyDictionary<string, FactionDefRow> rowsById)
{
foreach (var (fid, expected) in ExpectedFactionFreeze)
{
if (!rowsById.TryGetValue(fid, out var actual))
return $"error: missing faction '{fid}'";
if (!RowsEqual(expected, actual))
{
return
$"error: faction '{fid}' must match E7M3 freeze table " +
$"(expected displayName '{expected.DisplayName}', minStanding {expected.MinStanding}, " +
$"maxStanding {expected.MaxStanding}, neutralStanding {expected.NeutralStanding}; " +
$"got displayName '{actual.DisplayName}', minStanding {actual.MinStanding}, " +
$"maxStanding {actual.MaxStanding}, neutralStanding {actual.NeutralStanding})";
}
}
return null;
}
/// <summary>Returns a human-readable error if standing band ordering fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetStandingBandGateError(IReadOnlyDictionary<string, FactionDefRow> rowsById)
{
foreach (var (fid, row) in rowsById)
{
if (row.MinStanding > row.NeutralStanding || row.NeutralStanding > row.MaxStanding)
{
return
$"error: faction '{fid}' requires minStanding <= neutralStanding <= maxStanding " +
$"(got minStanding {row.MinStanding}, neutralStanding {row.NeutralStanding}, maxStanding {row.MaxStanding})";
}
}
return null;
}
private static bool RowsEqual(FactionDefRow left, FactionDefRow right) =>
left.DisplayName == right.DisplayName &&
left.MinStanding == right.MinStanding &&
left.MaxStanding == right.MaxStanding &&
left.NeutralStanding == right.NeutralStanding;
}

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Minimum standing gate on <see cref="QuestDefRow.FactionGateRules"/> (NEO-134).</summary>
public sealed record FactionGateRuleRow(string FactionId, int MinStanding);

View File

@ -0,0 +1,235 @@
using System.Collections.Frozen;
using NeonSprawl.Server.Game.Encounters;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Prototype E7M3 quest faction gates (NEO-134), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_*</c> and <c>_prototype_e7m3_*</c> gate functions.
/// </summary>
public static class PrototypeE7M3QuestFactionRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID</c>.</summary>
public const string GridContractGateFactionId = "prototype_faction_grid_operators";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING</c>.</summary>
public const int GridContractMinStanding = 15;
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID</c>.</summary>
public const string GridContractObjectiveItemId = "survey_drone_kit";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_COMPLETION_BUNDLES</c>.</summary>
public static readonly FrozenDictionary<string, QuestRewardBundleRow> ExpectedCompletionBundles =
new Dictionary<string, QuestRewardBundleRow>(StringComparer.Ordinal)
{
[PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = new(
[],
[new QuestSkillXpGrantRow("salvage", 25)],
[]),
[PrototypeE7M1QuestCatalogRules.RefineIntroQuestId] = new(
[],
[new QuestSkillXpGrantRow("refine", 25)],
[]),
[PrototypeE7M1QuestCatalogRules.CombatIntroQuestId] = new(
[],
[new QuestSkillXpGrantRow("salvage", 25)],
[]),
[PrototypeE7M1QuestCatalogRules.ChainQuestId] = new(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)],
[new ReputationGrantRow("prototype_faction_grid_operators", 15)]),
[PrototypeE7M1QuestCatalogRules.GridContractQuestId] = new(
[],
[],
[new ReputationGrantRow("prototype_faction_rust_collective", 10)]),
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if quest faction refs fail, otherwise <see langword="null"/>.</summary>
public static string? TryGetFactionCrossRefError(
IReadOnlyDictionary<string, QuestDefRow> rowsById,
IReadOnlySet<string> knownFactionIds)
{
foreach (var (qid, row) in rowsById)
{
for (var gi = 0; gi < row.FactionGateRules.Count; gi++)
{
var rule = row.FactionGateRules[gi];
if (!knownFactionIds.Contains(rule.FactionId))
{
return
$"error: quest '{qid}' factionGateRules[{gi}]: factionId '{rule.FactionId}' " +
"is not in the frozen prototype faction catalog";
}
}
if (row.CompletionRewardBundle is null)
continue;
for (var gi = 0; gi < row.CompletionRewardBundle.ReputationGrants.Count; gi++)
{
var grant = row.CompletionRewardBundle.ReputationGrants[gi];
if (!knownFactionIds.Contains(grant.FactionId))
{
return
$"error: quest '{qid}' completionRewardBundle.reputationGrants[{gi}]: factionId '{grant.FactionId}' " +
"is not in the frozen prototype faction catalog";
}
}
}
return null;
}
/// <summary>Returns a human-readable error if bundle contents diverge from the E7M3 freeze table, otherwise <see langword="null"/>.</summary>
public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
{
foreach (var (qid, expected) in ExpectedCompletionBundles)
{
if (!rowsById.TryGetValue(qid, out var row))
return $"error: missing quest '{qid}'";
if (row.CompletionRewardBundle is null)
return $"error: quest '{qid}' must include completionRewardBundle object";
var actual = NormalizeBundle(row.CompletionRewardBundle);
var expectedNormalized = NormalizeBundle(expected);
if (!BundlesEqual(actual, expectedNormalized))
{
return
$"error: quest '{qid}' completionRewardBundle must match E7M3 freeze table " +
$"(expected {FormatBundle(expectedNormalized)}, got {FormatBundle(actual)})";
}
}
return null;
}
/// <summary>Returns a human-readable error if grid contract quest shape fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetGridContractShapeError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
{
var qid = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
if (!rowsById.TryGetValue(qid, out var row))
return $"error: missing quest '{qid}'";
if (!row.PrerequisiteQuestIds.SequenceEqual(
[PrototypeE7M1QuestCatalogRules.ChainQuestId],
StringComparer.Ordinal))
{
var got = $"[{string.Join(", ", row.PrerequisiteQuestIds.Select(s => "'" + s + "'"))}]";
return
$"error: quest '{qid}' prerequisiteQuestIds must be ['{PrototypeE7M1QuestCatalogRules.ChainQuestId}'], got {got}";
}
if (row.FactionGateRules.Count != 1 ||
row.FactionGateRules[0].FactionId != GridContractGateFactionId ||
row.FactionGateRules[0].MinStanding != GridContractMinStanding)
{
var expected =
$"[{{ factionId: '{GridContractGateFactionId}', minStanding: {GridContractMinStanding} }}]";
var gotRules = string.Join(
", ",
row.FactionGateRules.Select(r => $"{{ factionId: '{r.FactionId}', minStanding: {r.MinStanding} }}"));
return $"error: quest '{qid}' factionGateRules must be {expected}, got [{gotRules}]";
}
if (row.Steps.Count != 1)
return $"error: quest '{qid}' must have exactly one step";
var step = row.Steps[0];
if (step.Objectives.Count != 1)
return $"error: quest '{qid}' must have exactly one objective";
var objective = step.Objectives[0];
if (objective.Kind != "inventory_has_item")
{
return $"error: quest '{qid}' objective kind must be 'inventory_has_item'";
}
if (objective.ItemId != GridContractObjectiveItemId)
{
return
$"error: quest '{qid}' objective itemId must be '{GridContractObjectiveItemId}', got '{objective.ItemId}'";
}
if (objective.Quantity != 1)
{
return $"error: quest '{qid}' objective quantity must be 1, got {objective.Quantity}";
}
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);
}

View File

@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
@ -25,6 +26,7 @@ public static class QuestCatalogServiceCollectionExtensions
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
var factionCatalog = sp.GetRequiredService<FactionDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog");
@ -50,6 +52,7 @@ public static class QuestCatalogServiceCollectionExtensions
var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownFactionIds = factionCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
return QuestDefinitionCatalogLoader.Load(
questsDir,
@ -62,6 +65,7 @@ public static class QuestCatalogServiceCollectionExtensions
knownItemIds,
knownRecipeIds,
knownEncounterIds,
knownFactionIds,
skillCatalog.ById,
logger);
});

View File

@ -1,12 +1,13 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Load-time quest row (NEO-113).</summary>
/// <summary>Load-time quest row (NEO-113, NEO-134).</summary>
public sealed class QuestDefRow(
string id,
string displayName,
IReadOnlyList<string> prerequisiteQuestIds,
IReadOnlyList<QuestStepDefRow> steps,
QuestRewardBundleRow? completionRewardBundle = null)
QuestRewardBundleRow? completionRewardBundle = null,
IReadOnlyList<FactionGateRuleRow>? factionGateRules = null)
{
public string Id { get; } = id;
@ -17,4 +18,6 @@ public sealed class QuestDefRow(
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle;
public IReadOnlyList<FactionGateRuleRow> FactionGateRules { get; } = factionGateRules ?? [];
}

View File

@ -8,7 +8,7 @@ using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113, NEO-125).</summary>
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113, NEO-125, NEO-134).</summary>
public static class QuestDefinitionCatalogLoader
{
private static JsonSchema? _questObjectiveDefSchema;
@ -26,6 +26,7 @@ public static class QuestDefinitionCatalogLoader
IReadOnlySet<string> knownItemIds,
IReadOnlySet<string> knownRecipeIds,
IReadOnlySet<string> knownEncounterIds,
IReadOnlySet<string> knownFactionIds,
IReadOnlyDictionary<string, SkillDefRow> skillDefsById,
ILogger logger)
{
@ -214,6 +215,27 @@ public static class QuestDefinitionCatalogLoader
ThrowIfAny(errors);
}
var factionCrossRef = PrototypeE7M3QuestFactionRules.TryGetFactionCrossRefError(rows, knownFactionIds);
if (factionCrossRef is not null)
{
errors.Add(factionCrossRef);
ThrowIfAny(errors);
}
var e7m3BundleFreeze = PrototypeE7M3QuestFactionRules.TryGetCompletionBundleFreezeError(rows);
if (e7m3BundleFreeze is not null)
{
errors.Add(e7m3BundleFreeze);
ThrowIfAny(errors);
}
var gridContractShape = PrototypeE7M3QuestFactionRules.TryGetGridContractShapeError(rows);
if (gridContractShape is not null)
{
errors.Add(gridContractShape);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
@ -306,7 +328,27 @@ public static class QuestDefinitionCatalogLoader
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray);
var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject);
return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle);
var factionGateRules = ParseFactionGateRules(rowObj["factionGateRules"] as JsonArray);
return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle, factionGateRules);
}
private static IReadOnlyList<FactionGateRuleRow> ParseFactionGateRules(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List<FactionGateRuleRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject ruleObj)
continue;
var factionId = (ruleObj["factionId"] as JsonValue)!.GetValue<string>();
var minStanding = (ruleObj["minStanding"] as JsonValue)!.GetValue<int>();
rows.Add(new FactionGateRuleRow(factionId, minStanding));
}
return rows;
}
private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj)
@ -316,7 +358,27 @@ public static class QuestDefinitionCatalogLoader
var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray);
var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray);
return new QuestRewardBundleRow(itemGrants, skillXpGrants);
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)

View File

@ -2,7 +2,16 @@ using NeonSprawl.Server.Game.Encounters;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Composite completion rewards on <see cref="QuestDefRow.CompletionRewardBundle"/> (NEO-125).</summary>
/// <summary>Composite completion rewards on <see cref="QuestDefRow.CompletionRewardBundle"/> (NEO-125, NEO-134).</summary>
public sealed record QuestRewardBundleRow(
IReadOnlyList<RewardGrantRow> ItemGrants,
IReadOnlyList<QuestSkillXpGrantRow> SkillXpGrants);
IReadOnlyList<QuestSkillXpGrantRow> SkillXpGrants,
IReadOnlyList<ReputationGrantRow> ReputationGrants)
{
public QuestRewardBundleRow(
IReadOnlyList<RewardGrantRow> itemGrants,
IReadOnlyList<QuestSkillXpGrantRow> skillXpGrants)
: this(itemGrants, skillXpGrants, [])
{
}
}

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Reputation grant row on <see cref="QuestRewardBundleRow.ReputationGrants"/> (NEO-134).</summary>
public sealed record ReputationGrantRow(string FactionId, int Amount);

View File

@ -154,4 +154,16 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of quests directory}/schemas/quest-objective-def.schema.json</c>.
/// </summary>
public string? QuestObjectiveDefSchemaPath { 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/factions</c> directory.
/// </summary>
public string? FactionsDirectory { get; set; }
/// <summary>
/// Optional override for <c>faction-def.schema.json</c>.
/// When unset, resolved as <c>{parent of factions directory}/schemas/faction-def.schema.json</c>.
/// </summary>
public string? FactionDefSchemaPath { get; set; }
}

View File

@ -2,6 +2,7 @@ using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Interaction;
@ -23,6 +24,7 @@ builder.Services.AddPerkStateStore(builder.Configuration);
builder.Services.AddAbilityCooldownStore();
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
builder.Services.AddFactionDefinitionCatalog(builder.Configuration);
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
builder.Services.AddResourceNodeCatalog(builder.Configuration);
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
@ -39,6 +41,7 @@ builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build();
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
_ = app.Services.GetRequiredService<FactionDefinitionCatalog>();
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();

View File

@ -50,6 +50,19 @@ On startup the host loads every **`*_items.json`** under the items directory, va
On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups (NEO-52).
## Faction catalog (`content/factions`, NEO-134)
On startup the host loads every **`*_factions.json`** under the factions directory **after** the skill catalog and **before** the quest catalog, validates each row against **`content/schemas/faction-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate faction `id`** values across files, enforces the **prototype E7M3** two-faction roster, freeze table (display names + standing band), and standing-band ordering (`minStanding <= neutralStanding <= maxStanding`), then the quest loader cross-checks **`factionGateRules`** and **`completionRewardBundle.reputationGrants`** against loaded faction ids and enforces **prototype E7M3** completion bundle freeze (item + skill + reputation grants) and **grid-contract** quest shape (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:FactionsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/factions`**. |
| **`Content:FactionDefSchemaPath`** | Optional override for **`faction-def.schema.json`**. When unset, **`{parent of factions directory}/schemas/faction-def.schema.json`**. |
**Docker / CI:** include **`content/factions`** and **`content/schemas/faction-def.schema.json`** in the mounted **`content/`** tree; set **`Content__FactionsDirectory`** when layout differs.
On success, **Information** logs include the resolved factions directory path, distinct faction count, and catalog file count. Game code should use **`IFactionDefinitionRegistry`** for lookups (NEO-134). The catalog singleton remains for fail-fast startup only; do not inject **`FactionDefinitionCatalog`** in new game code.
## Resource-node catalog (`content/resource-nodes`, NEO-58)
On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (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.
@ -135,7 +148,7 @@ On success, **Information** logs include the resolved encounters directory path,
## Quest catalog (`content/quests`, NEO-113)
On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, encounter, and **skill** catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`**, **`quest-objective-def.schema.json`**, **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, and **`reward-grant-row.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate, and enforces **prototype E7M2** **`completionRewardBundle`** presence, freeze-table contents, item/skill cross-refs, and **`mission_reward`** allowlist on skill XP targets (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.
On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, encounter, **skill**, and **faction** catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`**, **`quest-objective-def.schema.json`**, **`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 quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, enforces the **prototype E7M3** five-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate, enforces **prototype E7M2** **`completionRewardBundle`** presence, freeze-table contents (item + skill grants), item/skill cross-refs, and **`mission_reward`** allowlist on skill XP targets, and enforces **prototype E7M3** faction cross-refs on gates and reputation grants, E7M3 completion bundle freeze (including **`reputationGrants`**), and **grid-contract** quest shape (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 |
|--------|---------|
@ -146,7 +159,7 @@ On startup the host loads every **`*_quests.json`** under the quests directory *
Bundle-related schemas (**`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**) resolve as siblings under **`{parent of quests directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those three files next to the quest schemas (NEO-125).
**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**), and quest/bundle schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs.
**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**, **`content/factions`**), and quest/bundle/faction schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs.
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.