Merge pull request #173 from ViPro-Technologies/NEO-134-e7m3-server-faction-catalog-load-fail-fast

NEO-134: E7M3-02 server faction catalog load (fail-fast)
pull/177/head
VinPropane 2026-06-15 20:16:53 -04:00 committed by GitHub
commit d90d325a3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1592 additions and 12 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

@ -7,7 +7,7 @@
| **Module ID** | E7.M3 | | **Module ID** | E7.M3 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Pre-production | | **Stage target** | Pre-production |
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)): faction schemas + `prototype_factions.json` + quest gate/rep extensions + CI. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-02** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | | **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-03** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
## Purpose ## Purpose

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,207 @@
# NEO-134 — E7M3-02: Server faction catalog load (fail-fast)
**Linear:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)
**Branch:** `NEO-134-e7m3-server-faction-catalog-load-fail-fast`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-02**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-125-implementation-plan.md](NEO-125-implementation-plan.md) (catalog loader + CI-parity gates); [NEO-113](NEO-113-implementation-plan.md) (skill/item catalog DI shape)
**Precursor:** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) **`Done`** — faction schemas, `prototype_factions.json`, quest `factionGateRules` / `reputationGrants`, five-quest CI gates (**landed on `main`**)
## Goal
Fail-fast startup load of `content/factions/*_factions.json` with **CI-parity** validation; cross-check quest **`factionGateRules`** and **`completionRewardBundle.reputationGrants`** against the loaded faction catalog. Host refuses to listen when faction content or quest faction refs are invalid.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M3-02 backlog scope](E7M3-pre-production-backlog.md#e7m3-02--server-faction-catalog-load-fail-fast), kickoff decisions table, and NEO-125/NEO-133 precedents settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Module folder | `server/NeonSprawl.Server/Game/Factions/` | Backlog lists this path first; contracts are `FactionDef` / `IFactionDefinitionRegistry` |
| CI parity | Full E7M3 faction + quest faction gates on server | Backlog “CI-parity validation”; NEO-125 full E7M2 gate precedent |
| Quest DTO parse | Parse `factionGateRules` + `reputationGrants` onto typed rows now | NEO-125 parsed `completionRewardBundle` at load; NEO-137 gate eval needs rows |
| Load order | Faction catalog **before** quest catalog | Quest cross-ref gate needs `knownFactionIds` from faction registry |
| E7M2 bundle freeze | Keep existing item/skill freeze; **add** E7M3 freeze including rep | CI runs both `_prototype_e7m2_*` and `_prototype_e7m3_completion_bundle_freeze_gate` |
| `PrototypeE7M1QuestCatalogRules` rename | **Defer** full type rename | NEO-133 partial fix (error strings); rename is churn across many call sites — not in E7M3-02 AC |
| Standing store / apply / HTTP / Godot | **Out of scope** | Backlog E7M3-02; NEO-135+ |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- Faction catalog loader, catalog type, `IFactionDefinitionRegistry`, DI registration.
- `PrototypeE7M3FactionCatalogRules` — roster, freeze table, standing-band ordering (mirrors `scripts/validate_content.py` `PROTOTYPE_E7M3_FACTION_*` + `_prototype_e7m3_faction_*` gates).
- Quest loader extensions: parse `FactionGateRuleRow`, `ReputationGrantRow`; E7M3 quest gates — faction cross-ref, E7M3 completion bundle freeze (item + skill + rep), grid-contract shape.
- `ContentPathsOptions` + path resolution for `content/factions/`.
- Unit tests (AAA) for faction loader happy path + broken refs; quest loader faction cross-ref negatives; host boot resolves faction + quest catalogs.
- `server/README.md` faction catalog section.
**Out of scope:**
- Per-player standing store, reputation apply, gate evaluation on accept, HTTP projection (NEO-135NEO-139).
- Godot client (**client counterpart:** none — infrastructure-only).
## Acceptance criteria checklist
- [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
### 1. Faction DTOs and catalog (`Game/Factions/`)
| Type | Role |
|------|------|
| `FactionDefRow` | `Id`, `DisplayName`, `MinStanding`, `MaxStanding`, `NeutralStanding` |
| `FactionGateRuleRow` | `FactionId`, `MinStanding` (quest-side; lives in `Game/Quests/` or shared — prefer `Game/Quests/` next to quest rows) |
| `ReputationGrantRow` | `FactionId`, `Amount` (quest bundle row; `Game/Quests/`) |
| `FactionDefinitionCatalog` | Immutable `ById` map + directory metadata (mirror `SkillDefinitionCatalog`) |
| `IFactionDefinitionRegistry` / `FactionDefinitionRegistry` | `TryGetDefinition`, `GetDefinitionsInIdOrder` (mirror item registry) |
### 2. `FactionDefinitionCatalogLoader`
Mirror `SkillDefinitionCatalogLoader`:
- Enumerate `*_factions.json` under factions directory; require `schemaVersion` 1 and top-level `factions` array.
- JSON Schema validate each row against `faction-def.schema.json`.
- Reject duplicate faction ids across files.
- Run `PrototypeE7M3FactionCatalogRules`: roster (exactly two ids), freeze table (display names + standing band), standing-band ordering (`minStanding <= neutralStanding <= maxStanding`).
- Throw `InvalidOperationException` with sorted `error:` lines on failure.
### 3. `PrototypeE7M3FactionCatalogRules`
Sync to `scripts/validate_content.py`:
| Constant / helper | Python source |
|-------------------|---------------|
| `ExpectedFactionIds` | `PROTOTYPE_E7M3_FACTION_IDS` |
| `ExpectedFactionFreeze` | `PROTOTYPE_E7M3_FACTION_FREEZE` |
| `TryGetRosterGateError` | `_prototype_e7m3_faction_roster_gate` |
| `TryGetFreezeGateError` | `_prototype_e7m3_faction_freeze_gate` |
| `TryGetStandingBandGateError` | `_prototype_e7m3_faction_standing_band_gate` |
### 4. `PrototypeE7M3QuestFactionRules` (`Game/Quests/`)
Sync quest-side E7M3 gates:
| Helper | Python source |
|--------|---------------|
| `TryGetFactionCrossRefError(rows, knownFactionIds)` | `_prototype_e7m3_faction_cross_ref_gate` |
| `TryGetCompletionBundleFreezeError(rows)` | `_prototype_e7m3_completion_bundle_freeze_gate` (uses `PROTOTYPE_E7M3_COMPLETION_BUNDLES` incl. rep) |
| `TryGetGridContractShapeError(rows)` | `_prototype_e7m3_grid_contract_shape_gate` |
Extend **`QuestDefRow`** with optional `FactionGateRules`. Extend **`QuestRewardBundleRow`** with `ReputationGrants`. Update **`ParseRow`** / **`ParseCompletionRewardBundle`** in `QuestDefinitionCatalogLoader`.
Run E7M3 quest gates **after** E7M2 bundle gates, passing `knownFactionIds` from faction catalog into cross-ref.
### 5. Path resolution and DI
- Add `FactionsDirectory`, `FactionDefSchemaPath` to `ContentPathsOptions`.
- `FactionCatalogPathResolution` — discover `content/factions`, resolve `faction-def.schema.json` sibling under `content/schemas/`.
- `FactionCatalogServiceCollectionExtensions.AddFactionDefinitionCatalog` — singleton catalog + registry.
- **`Program.cs`:** register faction catalog **after** skill catalog, **before** quest catalog; eager-resolve `FactionDefinitionCatalog` at boot.
- **`QuestCatalogServiceCollectionExtensions`:** inject `FactionDefinitionCatalog`; pass `factionCatalog.ById.Keys` into quest loader.
### 6. Tests
**Faction loader (`FactionDefinitionCatalogLoaderTests`):**
- Happy path — repo `prototype_factions.json`.
- Missing factions directory / schema.
- Duplicate faction id.
- Wrong roster count or unknown id.
- Freeze table mismatch (display name or standing).
- Invalid standing band ordering.
**Quest loader extensions (`QuestDefinitionCatalogLoaderTests`):**
- Unknown `factionId` in `factionGateRules`.
- Unknown `factionId` in `reputationGrants`.
- E7M3 bundle freeze mismatch (operator-chain rep row).
- Grid-contract shape violation (wrong `minStanding` or objective).
- Existing repo prototype load + `Host_ShouldResolveQuestCatalogFromDi` still pass.
**Registry (`FactionDefinitionRegistryTests`):** `TryGetDefinition` hit/miss; id-order listing.
### 7. Docs
- **`server/README.md`** — new faction catalog paragraph (config keys, gates, load order before quests).
- Optional one-line E7_M3 module status when implementation lands.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs` | Load-time faction row |
| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs` | In-memory catalog |
| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs` | Fail-fast disk load |
| `server/NeonSprawl.Server/Game/Factions/FactionCatalogPathResolution.cs` | Directory + schema path discovery |
| `server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs` | DI registration |
| `server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs` | Lookup contract |
| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs` | Registry adapter |
| `server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs` | CI-parity faction gates |
| `server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs` | Quest gate row |
| `server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs` | Bundle rep grant row |
| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs` | Quest-side E7M3 faction gates |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs` | Loader AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs` | Registry AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs` | Repo path discovery for tests |
## Files to modify
| Path | Change |
|------|--------|
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `FactionsDirectory`, `FactionDefSchemaPath` |
| `server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs` | Optional `FactionGateRules` property |
| `server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs` | Add `ReputationGrants` list |
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs` | Parse faction fields; accept `knownFactionIds`; run E7M3 quest faction gates |
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | Inject faction catalog into quest loader |
| `server/NeonSprawl.Server/Program.cs` | Register + eager-resolve faction catalog before quests |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | E7M3 negative cases; extend `LoadCatalog` helper with faction ids |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs` | Faction directory/schema discovery helpers |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Ensure host boot resolves faction catalog if needed |
| `server/README.md` | Faction catalog section |
## Tests
| Layer | What |
|-------|------|
| `FactionDefinitionCatalogLoaderTests` | Happy path, missing paths, duplicate id, roster/freeze/standing-band gates |
| `FactionDefinitionRegistryTests` | Lookup by id, ordered listing |
| `QuestDefinitionCatalogLoaderTests` | Faction cross-ref, E7M3 bundle freeze, grid-contract shape; repo prototype + host boot |
| CI | Existing `dotnet test` + `validate_content.py` (no Python changes expected) |
Manual QA: **none** (server infrastructure; Godot capstone is NEO-143).
## Implementation order
1. Faction DTOs, rules, path resolution, loader, catalog, registry, DI.
2. Quest DTO extensions + parse + `PrototypeE7M3QuestFactionRules` + wire into quest loader.
3. `Program.cs` load order + `ContentPathsOptions`.
4. Tests (faction loader → quest negatives → host boot).
5. `server/README.md`.
6. Run `dotnet test` on server project.
## Open questions / risks
| Item | Agent recommendation | Status |
|------|---------------------|--------|
| Rename `PrototypeE7M1QuestCatalogRules``PrototypeE7M3QuestCatalogRules` | Defer — not required for AC; large rename across tests and call sites | `deferred` |
| E7M2 vs E7M3 bundle freeze overlap | Keep both gates (CI does); E7M2 item/skill only, E7M3 adds rep rows | `adopted` |
| Quest loader already registers `faction-gate-rule` / `reputation-grant-row` schemas (NEO-133) | Reuse; no schema registration changes needed | `adopted` |
## Client counterpart
None for E7M3-02. Client faction HUD: **NEO-142** / capstone **NEO-143**.
## Blocks
**NEO-135** (faction standing store) is blocked until faction catalog load lands.

View File

@ -0,0 +1,55 @@
# Code review — NEO-134 (E7M3-02)
**Date:** 2026-06-15
**Scope:** Branch `NEO-134-e7m3-server-faction-catalog-load-fail-fast` — commits `edb3351``48bca98` vs `main`
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
This branch lands **E7M3-02**: fail-fast startup load of `content/factions/*_factions.json` with CI-parity roster/freeze/standing-band gates, `IFactionDefinitionRegistry`, and quest-loader extensions that parse `factionGateRules` / `reputationGrants` and run E7M3 cross-ref, completion-bundle freeze (including rep rows), and grid-contract shape gates. Load order is correct (skills → factions → quests via DI dependency). The implementation mirrors established catalog patterns (`SkillDefinitionCatalogLoader`, NEO-125 quest gates) and keeps Python/C# gate constants in sync. Test coverage is strong (faction loader negatives, registry + host boot, four new quest-loader negatives); `746` server tests and `validate_content.py` pass locally. Risk is low — infrastructure-only, no player-facing runtime behavior yet. Minor gaps: decomposition tracking docs not updated for E7M3-02, and two plan-listed loader negative cases are only partially covered.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-134-implementation-plan.md` | **Matches** — shipped items reconcile with plan (faction catalog, quest extensions, DI/load order, tests, README). AC checklist marked complete. |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-02 scope (server load, CI parity, no client counterpart) aligns with implementation. |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-02 server load landed (NEO-134). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-02 / NEO-134. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register update required for this slice. |
| `server/README.md` | **Matches** — new faction catalog section; quest catalog paragraph updated for E7M3 gates and faction dependency. |
| `content/README.md` | **N/A** — no content changes in this branch. |
## Blocking issues
None.
## Suggestions
1. ~~**Decomposition status** — Update `E7_M3_FactionReputationLedger.md` **Status** (one line) and `documentation_and_implementation_alignment.md` E7.M3 row to record **E7M3-02 server faction catalog load** landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)). Plan §7 lists this as optional but it keeps module tracking honest for NEO-135 consumers.~~ **Done.** Module status + alignment table updated for E7M3-02 / NEO-134.
2. ~~**Standing-band loader integration test** — Plan §6 lists “Invalid standing band ordering” under `FactionDefinitionCatalogLoaderTests`. `TryGetStandingBandGateError_ShouldReturnError_WhenOrderingFails` covers the rule in isolation; add a `Load_ShouldThrow_WhenStandingBandOrderingFails` that writes invalid `minStanding`/`neutralStanding` JSON through the full loader path (cheap regression lock).~~ **Done.** Added `Load_ShouldThrow_WhenStandingBandOrderingFails`; loader runs standing-band gate before freeze so the integration path reports the band error.
3. ~~**Missing factions directory test** — Plan §6 also lists “Missing factions directory / schema.” Schema-missing is covered; add `Load_ShouldThrow_WhenFactionsDirectoryMissing` for parity with other catalog loader test suites.~~ **Done.**
## Nits
- ~~Nit: `FactionDefinitionCatalogLoaderTests.cs` imports `Microsoft.Extensions.DependencyInjection` but does not use it.~~ **Done.**
- ~~Nit: `FactionDefinitionCatalogLoader` logs unconditionally on success while `QuestDefinitionCatalogLoader` guards with `logger.IsEnabled(LogLevel.Information)` — harmless, but inconsistent with sibling loaders.~~ **Done.**
## Verification
Commands run during review (passed):
```bash
dotnet test server/NeonSprawl.Server.Tests
python3 scripts/validate_content.py
```
Author should confirm PR CI green before merge. Bruno smoke (`bruno/neon-sprawl-server/faction-catalog/`) is appropriate — no manual QA doc required per plan (infrastructure-only).
**Intentional follow-on (not blockers):** Standing store, gate evaluation on accept, HTTP projection, and Godot HUD remain NEO-135NEO-143. `PrototypeE7M1QuestCatalogRules` full rename remains deferred per plan.

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,217 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Factions;
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_WhenStandingBandOrderingFails()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var badBand = ValidPrototypeCatalogJson.Replace("\"minStanding\": -100", "\"minStanding\": 10", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), badBand, Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("requires minStanding <= neutralStanding <= maxStanding", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenFactionsDirectoryMissing()
{
// Arrange
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-factions-" + Guid.NewGuid().ToString("n"));
var schemaPath = FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath();
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
Assert.Contains(missingDir, ioe.Message, 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() => internal static string DiscoverRepoReputationGrantRowSchemaPath() =>
Path.GetFullPath( Path.GetFullPath(
Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "reputation-grant-row.schema.json")); 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 Microsoft.Extensions.Logging.Abstractions;
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.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
@ -18,6 +19,8 @@ namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionCatalogLoaderTests public class QuestDefinitionCatalogLoaderTests
{ {
private static readonly FrozenSet<string> KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds;
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds; private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds; private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds;
@ -247,7 +250,8 @@ public class QuestDefinitionCatalogLoaderTests
string defSchemaPath, string defSchemaPath,
string stepSchemaPath, string stepSchemaPath,
string objectiveSchemaPath, string objectiveSchemaPath,
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null) => IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null,
IReadOnlySet<string>? knownFactionIds = null) =>
QuestDefinitionCatalogLoader.Load( QuestDefinitionCatalogLoader.Load(
questsDir, questsDir,
defSchemaPath, defSchemaPath,
@ -259,6 +263,7 @@ public class QuestDefinitionCatalogLoaderTests
KnownItemIds, KnownItemIds,
KnownRecipeIds, KnownRecipeIds,
KnownEncounterIds, KnownEncounterIds,
knownFactionIds ?? KnownFactionIds,
skillDefsById ?? RepoSkillDefs, skillDefsById ?? RepoSkillDefs,
NullLogger.Instance); NullLogger.Instance);
@ -752,6 +757,88 @@ public class QuestDefinitionCatalogLoaderTests
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal); 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] [Fact]
public void Load_ShouldThrow_WhenMissingSchemaFile() 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.Combat;
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.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
@ -62,6 +63,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory) var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "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:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir); builder.UseSetting("Content:ItemsDirectory", itemsDir);
@ -71,6 +75,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir); builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
builder.UseSetting("Content:EncountersDirectory", encountersDir); builder.UseSetting("Content:EncountersDirectory", encountersDir);
builder.UseSetting("Content:FactionsDirectory", factionsDir);
builder.UseSetting("Content:QuestsDirectory", questsDir); builder.UseSetting("Content:QuestsDirectory", questsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); 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,201 @@
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 standingBand = PrototypeE7M3FactionCatalogRules.TryGetStandingBandGateError(rows);
if (standingBand is not null)
{
errors.Add(standingBand);
ThrowIfAny(errors);
}
var freeze = PrototypeE7M3FactionCatalogRules.TryGetFreezeGateError(rows);
if (freeze is not null)
{
errors.Add(freeze);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
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 Microsoft.Extensions.Options;
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.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
@ -25,6 +26,7 @@ public static class QuestCatalogServiceCollectionExtensions
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>(); var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>(); var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>(); var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
var factionCatalog = sp.GetRequiredService<FactionDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>() var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog"); .CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog");
@ -50,6 +52,7 @@ public static class QuestCatalogServiceCollectionExtensions
var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownFactionIds = factionCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
return QuestDefinitionCatalogLoader.Load( return QuestDefinitionCatalogLoader.Load(
questsDir, questsDir,
@ -62,6 +65,7 @@ public static class QuestCatalogServiceCollectionExtensions
knownItemIds, knownItemIds,
knownRecipeIds, knownRecipeIds,
knownEncounterIds, knownEncounterIds,
knownFactionIds,
skillCatalog.ById, skillCatalog.ById,
logger); logger);
}); });

View File

@ -1,12 +1,13 @@
namespace NeonSprawl.Server.Game.Quests; 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( public sealed class QuestDefRow(
string id, string id,
string displayName, string displayName,
IReadOnlyList<string> prerequisiteQuestIds, IReadOnlyList<string> prerequisiteQuestIds,
IReadOnlyList<QuestStepDefRow> steps, IReadOnlyList<QuestStepDefRow> steps,
QuestRewardBundleRow? completionRewardBundle = null) QuestRewardBundleRow? completionRewardBundle = null,
IReadOnlyList<FactionGateRuleRow>? factionGateRules = null)
{ {
public string Id { get; } = id; public string Id { get; } = id;
@ -17,4 +18,6 @@ public sealed class QuestDefRow(
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps; public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle; 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; 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 public static class QuestDefinitionCatalogLoader
{ {
private static JsonSchema? _questObjectiveDefSchema; private static JsonSchema? _questObjectiveDefSchema;
@ -26,6 +26,7 @@ public static class QuestDefinitionCatalogLoader
IReadOnlySet<string> knownItemIds, IReadOnlySet<string> knownItemIds,
IReadOnlySet<string> knownRecipeIds, IReadOnlySet<string> knownRecipeIds,
IReadOnlySet<string> knownEncounterIds, IReadOnlySet<string> knownEncounterIds,
IReadOnlySet<string> knownFactionIds,
IReadOnlyDictionary<string, SkillDefRow> skillDefsById, IReadOnlyDictionary<string, SkillDefRow> skillDefsById,
ILogger logger) ILogger logger)
{ {
@ -214,6 +215,27 @@ public static class QuestDefinitionCatalogLoader
ThrowIfAny(errors); 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)) if (logger.IsEnabled(LogLevel.Information))
{ {
logger.LogInformation( logger.LogInformation(
@ -306,7 +328,27 @@ public static class QuestDefinitionCatalogLoader
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>(); var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray); var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray);
var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject); 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) private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj)
@ -316,7 +358,27 @@ public static class QuestDefinitionCatalogLoader
var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray); var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray);
var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] 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) private static IReadOnlyList<RewardGrantRow> ParseItemGrantRows(JsonArray? array)

View File

@ -2,7 +2,16 @@ using NeonSprawl.Server.Game.Encounters;
namespace NeonSprawl.Server.Game.Quests; 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( public sealed record QuestRewardBundleRow(
IReadOnlyList<RewardGrantRow> ItemGrants, 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>. /// When unset, resolved as <c>{parent of quests directory}/schemas/quest-objective-def.schema.json</c>.
/// </summary> /// </summary>
public string? QuestObjectiveDefSchemaPath { get; set; } 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.Combat;
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.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.Interaction;
@ -23,6 +24,7 @@ builder.Services.AddPerkStateStore(builder.Configuration);
builder.Services.AddAbilityCooldownStore(); builder.Services.AddAbilityCooldownStore();
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>(); builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
builder.Services.AddSkillDefinitionCatalog(builder.Configuration); builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
builder.Services.AddFactionDefinitionCatalog(builder.Configuration);
builder.Services.AddItemDefinitionCatalog(builder.Configuration); builder.Services.AddItemDefinitionCatalog(builder.Configuration);
builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddResourceNodeCatalog(builder.Configuration);
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
@ -39,6 +41,7 @@ builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build(); var app = builder.Build();
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>(); _ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
_ = app.Services.GetRequiredService<FactionDefinitionCatalog>();
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>(); _ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
_ = app.Services.GetRequiredService<ResourceNodeCatalog>(); _ = app.Services.GetRequiredService<ResourceNodeCatalog>();
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>(); _ = 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). 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) ## 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. 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) ## 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 | | 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). 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. 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.