Compare commits

..

6 Commits

11 changed files with 536 additions and 12 deletions

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M1 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Prototype |
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; registry + runtime from **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; runtime from **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
## Purpose

File diff suppressed because one or more lines are too long

View File

@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|---|---|---|---|---|---|
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned |
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; register row stays **Planned** until **NEO-114** registry + quest runtime land.
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); register row stays **Planned** until quest runtime (E7M1-05+) lands.
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned |
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |

View File

@ -128,13 +128,13 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
### E7M1-03 — Injectable quest definition registry + DI
**Goal:** **`IQuestDefinitionRegistry`** with **`TryGet`**, startup registration from E7M1-02 catalog.
**Goal:** **`IQuestDefinitionRegistry`** with **`TryGetDefinition`**, **`TryNormalizeKnown`**, and **`GetDefinitionsInIdOrder`**, startup registration from E7M1-02 catalog.
**In scope**
- Interface + in-memory implementation; **`AddQuestDefinitionRegistry`** DI extension.
- Unit tests: lookup known ids, unknown id returns false.
- Wire in host startup alongside other content registries.
- Interface + in-memory implementation; extend **`AddQuestDefinitionCatalog`** to register **`IQuestDefinitionRegistry`** after the catalog singleton.
- Unit tests: lookup known ids; unknown / null / empty id returns false; normalize trim/case; enumeration order; host DI resolve.
- Wire registry in **`AddQuestDefinitionCatalog`** alongside catalog load (catalog remains eager boot surface).
**Out of scope**
@ -142,8 +142,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
**Acceptance criteria**
- [ ] Registry resolves all four frozen quest ids at runtime.
- [ ] Unknown quest id fails closed for downstream operations.
- [x] Registry resolves all four frozen quest ids at runtime.
- [x] Unknown quest id fails closed for downstream operations.
**Client counterpart:** none (infrastructure-only).

View File

@ -0,0 +1,110 @@
# NEO-114 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-114 |
| **Title** | E7M1-03: Injectable quest definition registry + DI |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-114/e7m1-03-injectable-quest-definition-registry-di |
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-03** |
| **Branch** | `NEO-114-e7m1-injectable-quest-definition-registry-di` |
| **Precursor** | [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) — fail-fast quest catalog load (**landed on `main`**) |
| **Pattern** | [NEO-102](NEO-102-implementation-plan.md) / [NEO-52](NEO-52-implementation-plan.md) — thin registry adapter over startup catalog + DI; strict split after NEO-113 loader |
| **Blocks** | [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) — GET `/game/world/quest-definitions`; E7M1-05+ quest runtime |
| **Client counterpart** | None — server-only registry surface; player-visible work starts at [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) / [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Registry API surface** | Include `TryNormalizeKnown` + `GetDefinitionsInIdOrder` on `IQuestDefinitionRegistry`? | **Yes** — mirror [NEO-102](NEO-102-implementation-plan.md) / `IEncounterDefinitionRegistry`; [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) (E7M1-04 HTTP) needs enumeration; accept flow will need fail-closed id normalization. | **Adopted** — user chose full surface. |
| **Lookup naming** | `TryGetDefinition` vs `TryGetQuest`? | **`TryGetDefinition`** — item/skill/ability/recipe/encounter precedent; catalog keeps `TryGetQuest`. | **Adopted** |
| **DI wiring** | Separate `AddQuestDefinitionRegistry` extension vs extend catalog registration? | **Extend `AddQuestDefinitionCatalog`** — register `IQuestDefinitionRegistry` after `QuestDefinitionCatalog` singleton (same file as NEO-113). | **Adopted** |
| **Test scope** | Registry unit tests only, or also host DI resolve? | **Both** — mirror NEO-102; NEO-113 already covers catalog boot. | **Adopted** |
| **Program.cs eager resolve** | Eager-resolve registry at boot? | **Omit** — catalog singleton already eager-resolved (NEO-113); registry is thin adapter. | **Adopted** |
| **Call site migration** | Migrate existing consumers off `QuestDefinitionCatalog`? | **None this story** — no game code injects quest catalog yet; HTTP is NEO-115. | **Adopted** |
## Goal, scope, and out-of-scope
**Goal:** Provide **`IQuestDefinitionRegistry`** backed by the startup-loaded **`QuestDefinitionCatalog`**: resolve by stable quest id, enumerate definitions in id order, and expose **`TryNormalizeKnown`** for fail-closed id validation. Register in DI so **NEO-115+** (HTTP, player quest state, accept/advance engines) depend on the interface instead of the catalog type.
**In scope (from Linear + [E7M1-03](E7M1-prototype-backlog.md#e7m1-03--injectable-quest-definition-registry--di)):**
- `IQuestDefinitionRegistry` + `QuestDefinitionRegistry` thin adapter over NEO-113 catalog.
- DI registration in `AddQuestDefinitionCatalog`.
- Unit tests (AAA): all four frozen quest ids resolve with expected metadata; unknown id / null miss without throwing; `TryNormalizeKnown` trim/case/whitespace behavior; enumeration order; host resolves registry from DI.
**Out of scope (from Linear + backlog):**
- HTTP routes ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) / E7M1-04).
- Player quest state, accept/advance hooks (E7M1-05+).
- Changing loader, E7M1 gates, or catalog load semantics (NEO-113).
- Godot / client changes.
## Acceptance criteria checklist
- [x] Registry resolves all four frozen quest ids at runtime (`PrototypeE7M1QuestCatalogRules.ExpectedQuestIds`).
- [x] Unknown quest id fails closed (`TryGetDefinition` / `TryNormalizeKnown` return false; no throw).
## Implementation reconciliation (shipped)
- **Interface:** `IQuestDefinitionRegistry` with `TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`.
- **Adapter:** `QuestDefinitionRegistry` over NEO-113 `QuestDefinitionCatalog` (cached id-order list).
- **DI:** `AddQuestDefinitionCatalog` registers `IQuestDefinitionRegistry` after catalog singleton; no `Program.cs` change.
- **Tests:** AAA tests in `QuestDefinitionRegistryTests` (unit + host DI; four frozen ids + unknown/null/normalize/order).
- **Docs:** `server/README.md` quest registry section; alignment register E7.M1 row updated.
## Technical approach
1. **`IQuestDefinitionRegistry`** — mirror [`IEncounterDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Encounters/IEncounterDefinitionRegistry.cs):
- `TryGetDefinition(string? questId, [NotNullWhen(true)] out QuestDefRow? definition)` — null and unknown ids return false without throwing.
- `TryNormalizeKnown(string? rawQuestId, [NotNullWhen(true)] out string normalized)` — trim + lowercase + catalog lookup; null/empty/whitespace return false.
- `GetDefinitionsInIdOrder()` — all rows ordered by `QuestDefRow.Id` (ordinal).
- XML remarks: quest runtime / accept callers (NEO-116+) and HTTP read model (NEO-115) should use this interface, not `QuestDefinitionCatalog`.
2. **`QuestDefinitionRegistry`** — primary-constructor adapter delegating to `QuestDefinitionCatalog.TryGetQuest`; cache `GetDefinitionsInIdOrder` at construction (NEO-102 / NEO-79 pattern).
3. **DI** — extend [`QuestCatalogServiceCollectionExtensions.AddQuestDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs):
- `services.AddSingleton<IQuestDefinitionRegistry>(sp => new QuestDefinitionRegistry(sp.GetRequiredService<QuestDefinitionCatalog>()));`
- Update extension XML summary to document both catalog + registry registration.
- No change to `Program.cs` eager-resolve (catalog only).
4. **Comments / docs**`QuestDefinitionCatalog` summary already references NEO-114; update [server README](../../server/README.md) quest catalog section to document registry as preferred lookup surface (replace “will be” placeholder with concrete method list, mirror encounter section).
5. **Tests** — new `QuestDefinitionRegistryTests.cs` mirroring [`EncounterDefinitionRegistryTests`](../../server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs):
- **Unit (in-memory catalog fixture):** each of four `ExpectedQuestIds` resolves via `TryGetDefinition` with non-empty `DisplayName` and step count smoke; null/unknown/empty `TryGetDefinition` false; `TryNormalizeKnown` success for trimmed mixed-case known id; false for null/whitespace/unknown; `GetDefinitionsInIdOrder` length 4 and ordinal id order.
- **Host:** `InMemoryWebApplicationFactory` + `/health` + DI resolve `IQuestDefinitionRegistry`; `TryGetDefinition("prototype_quest_gather_intro")` true; unknown id false; `TryNormalizeKnown` on a frozen id.
6. **Alignment register** — when complete, note NEO-114 registry in `documentation_and_implementation_alignment.md` E7.M1 row (registry landed; runtime still Planned until E7M1-05+).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Quests/IQuestDefinitionRegistry.cs` | Public contract: lookup, normalize, enumerate. |
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionRegistry.cs` | Thin adapter over `QuestDefinitionCatalog`. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs` | AAA unit + host DI tests for registry. |
| `docs/plans/NEO-114-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | Register `IQuestDefinitionRegistry` after catalog singleton; update doc comment. |
| `server/README.md` | Quest catalog section — registry is preferred lookup surface (methods + normalization note). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — note NEO-114 registry when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` true + metadata for each `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` member (at least `displayName` + `Steps.Count` smoke on gather intro and operator chain); false for null/unknown. **Unit:** `TryNormalizeKnown` trim/case success on known id; false for null/whitespace/unknown. **Unit:** `GetDefinitionsInIdOrder` returns 4 rows in ordinal id order. **Host:** `InMemoryWebApplicationFactory` resolves `IQuestDefinitionRegistry`; frozen id lookup; unknown + normalize failures. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Case sensitivity on raw lookup** | `TryGetDefinition` is case-sensitive on catalog keys; wire validation uses `TryNormalizeKnown` (document in README like encounters). | **adopted** |
| **Constants for tests** | Reuse `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` / named ids in tests; avoid duplicating id strings. | **adopted** |
| **Registry vs catalog injection in new code** | No new `QuestDefinitionCatalog` injections this repo pass; enforcement via XML + README only until analyzers exist. | **adopted** |

View File

@ -0,0 +1,61 @@
# Code review — NEO-114 (E7M1-03)
**Date:** 2026-06-01
**Scope:** Branch `NEO-114-e7m1-injectable-quest-definition-registry-di` vs `d236aee` (merge-base on `main`) — commits `9056a9b``0bcbb4b`
**Base:** `d236aee` (main at branch point, post NEO-113 merge)
## Verdict
**Approve with nits**
## Summary
NEO-114 adds **`IQuestDefinitionRegistry`** and **`QuestDefinitionRegistry`**, a thin adapter over the NEO-113 **`QuestDefinitionCatalog`**, mirroring the established NEO-102 encounter registry pattern: `TryGetDefinition`, `TryNormalizeKnown`, and cached `GetDefinitionsInIdOrder`. DI registers the registry in **`AddQuestDefinitionCatalog`** after the catalog singleton; no `Program.cs` change (catalog remains the eager boot surface). Fourteen AAA unit + host tests pass locally; documentation (implementation plan, alignment register, module register, E7.M1 status, backlog AC, `server/README.md`) is updated. Server-only infrastructure with no client counterpart (correct). Risk is low — predictable adapter with strong parity to encounter registry and good host integration coverage.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-114-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation accurate. |
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-03) | **Matches** — AC checked; **In scope** wording aligned with shipped API (NEO-114 review follow-up). |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — Status notes E7M1-03 / NEO-114 registry landed; runtime from E7M1-04. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M1 row notes NEO-114 registry; register stays Planned until E7M1-05+ runtime. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 note updated for NEO-114 registry landed. |
| `server/README.md` | **Matches** — quest catalog section documents registry as preferred lookup surface with normalization note. |
| Full-stack epic decomposition | **N/A** — infrastructure-only; no client counterpart until NEO-122/NEO-123. |
## Blocking issues
(none)
## Suggestions
1. ~~**Refresh E7M1-03 backlog wording** — Update `docs/plans/E7M1-prototype-backlog.md` **In scope** to match shipped API: `TryGetDefinition` (not `TryGet`), extend `AddQuestDefinitionCatalog` (not separate `AddQuestDefinitionRegistry`), and optional mention of `TryNormalizeKnown` / `GetDefinitionsInIdOrder` per the implementation plan.~~ **Done.**
2. ~~**`TryGetDefinition` empty-string test** — Implementation plan test table lists false for null/unknown/**empty**; tests cover null and unknown but not `""`. Encounter registry has the same gap; adding one unit test would close the plan loop and guard accidental empty-key catalog entries.~~ **Done.**`TryGetDefinition_ShouldReturnFalse_WhenQuestIdIsEmpty`.
## Nits
- Nit: `GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleQuests` uses three synthetic rows (ordering smoke) while the host test validates four frozen ids — intentional split; no change required.
- Nit: `[Theory]` over `ExpectedQuestIds` uses minimal stub rows (`DisplayName: "Test Quest"`) while gather intro / operator chain have richer metadata tests; host test asserts non-empty display names for all four — adequate coverage.
- Nit: `QuestDefinitionCatalog` XML and README correctly steer new code to `IQuestDefinitionRegistry` — good guardrail ahead of NEO-115 HTTP.
## Verification
```bash
# Quest registry tests (14 — primary signal for this story)
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~QuestDefinitionRegistryTests"
# Quest catalog loader tests (unchanged; regression)
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~QuestDefinitionCatalogLoaderTests"
# Full server suite (CI)
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
**Reviewer note:** All 14 `QuestDefinitionRegistryTests` passed locally during review. After follow-up: 15 tests (empty-string `TryGetDefinition` case).

View File

@ -0,0 +1,272 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionRegistryTests
{
private const string GatherIntroId = "prototype_quest_gather_intro";
private const string OperatorChainId = "prototype_quest_operator_chain";
public static TheoryData<string> FrozenQuestIds =>
new(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal));
private static QuestDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, QuestDefRow> byId)
{
var catalog = new QuestDefinitionCatalog("/tmp/quests", byId, catalogJsonFileCount: 1);
return new QuestDefinitionRegistry(catalog);
}
private static QuestDefRow CreateGatherIntroRow() =>
new(
GatherIntroId,
"Intro: Salvage Run",
[],
[
new QuestStepDefRow(
"gather_intro_step_salvage",
"Gather scrap metal",
[
new QuestObjectiveDefRow(
"gather_intro_obj_scrap",
"gather_item",
"scrap_metal_bulk",
3,
null,
null),
]),
]);
[Theory]
[MemberData(nameof(FrozenQuestIds))]
public void TryGetDefinition_ShouldReturnTrue_WhenFrozenQuestIdExists(string questId)
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[questId] = new QuestDefRow(questId, "Test Quest", [], []),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(questId, out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal(questId, def!.Id);
Assert.Equal("Test Quest", def.DisplayName);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenGatherIntroExists()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[GatherIntroId] = CreateGatherIntroRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(GatherIntroId, out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal("Intro: Salvage Run", def!.DisplayName);
Assert.Single(def.Steps);
Assert.Equal("gather_intro_step_salvage", def.Steps[0].Id);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndFourSteps_WhenOperatorChainExists()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[OperatorChainId] = new QuestDefRow(
OperatorChainId,
"Operator Chain",
["prototype_quest_gather_intro"],
[
new QuestStepDefRow("chain_step_gather", "Bulk salvage", []),
new QuestStepDefRow("chain_step_refine", "Refine stock", []),
new QuestStepDefRow("chain_step_stim", "Craft field stim", []),
new QuestStepDefRow("chain_step_token", "Hand off contract token", []),
]),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(OperatorChainId, out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal(4, def!.Steps.Count);
Assert.Equal("chain_step_token", def.Steps[3].Id);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenQuestIdIsNull()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[GatherIntroId] = CreateGatherIntroRow(),
};
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_WhenQuestIdIsEmpty()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[GatherIntroId] = CreateGatherIntroRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(string.Empty, out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[GatherIntroId] = CreateGatherIntroRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_quest", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[GatherIntroId] = CreateGatherIntroRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown(" Prototype_Quest_Gather_Intro ", out var normalized);
// Assert
Assert.True(found);
Assert.Equal(GatherIntroId, normalized);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[GatherIntroId] = CreateGatherIntroRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown(null, out var normalized);
// Assert
Assert.False(found);
Assert.Equal(string.Empty, normalized);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[GatherIntroId] = CreateGatherIntroRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown(" ", out var normalized);
// Assert
Assert.False(found);
Assert.Equal(string.Empty, normalized);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnFalse_WhenIdNotInCatalog()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
[GatherIntroId] = CreateGatherIntroRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown("prototype_unknown", out var normalized);
// Assert
Assert.False(found);
Assert.Equal("prototype_unknown", normalized);
}
[Fact]
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleQuests()
{
// Arrange
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
{
["z_quest"] = new QuestDefRow("z_quest", "Z", [], []),
[GatherIntroId] = CreateGatherIntroRow(),
["a_quest"] = new QuestDefRow("a_quest", "A", [], []),
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(3, list.Count);
Assert.Equal("a_quest", list[0].Id);
Assert.Equal(GatherIntroId, list[1].Id);
Assert.Equal("z_quest", list[2].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<IQuestDefinitionRegistry>();
var gatherFound = registry.TryGetDefinition(GatherIntroId, out var gather);
var unknown = registry.TryGetDefinition("not_a_real_quest", out var missing);
var normalized = registry.TryNormalizeKnown(" Prototype_Quest_Gather_Intro ", out var questId);
var unknownNormalized = registry.TryNormalizeKnown("prototype_unknown", out var unknownQuestId);
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.True(gatherFound);
Assert.NotNull(gather);
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);
Assert.False(unknown);
Assert.Null(missing);
Assert.True(normalized);
Assert.Equal(GatherIntroId, questId);
Assert.False(unknownNormalized);
Assert.Equal("prototype_unknown", unknownQuestId);
Assert.Equal(4, list.Count);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal), list.Select(q => q.Id));
foreach (var expectedId in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds)
{
Assert.True(registry.TryGetDefinition(expectedId, out var def));
Assert.NotNull(def);
Assert.False(string.IsNullOrWhiteSpace(def!.DisplayName));
}
}
}

View File

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Read-only access to validated <see cref="QuestDefRow"/> entries loaded at startup (<see cref="QuestDefinitionCatalog"/>).
/// </summary>
/// <remarks>
/// <para><b>E7.M1 (quest accept / progress / objective advance):</b> callers validating quest ids and resolving quest metadata should depend on this interface
/// rather than <see cref="QuestDefinitionCatalog"/> so quest defs stay centralized.</para>
/// <para><b>NEO-115:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
/// </remarks>
public interface IQuestDefinitionRegistry
{
/// <summary>Attempts to resolve a quest by stable <c>id</c> (see <c>quest-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? questId, [NotNullWhen(true)] out QuestDefRow? definition);
/// <summary>Trims and lowercases <paramref name="rawQuestId"/>; returns <c>true</c> when the normalized id exists in the loaded catalog. <c>null</c>, empty, and whitespace return <c>false</c> without throwing.</summary>
bool TryNormalizeKnown(string? rawQuestId, [NotNullWhen(true)] out string normalized);
/// <summary>Every loaded definition, ordered by <see cref="QuestDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<QuestDefRow> GetDefinitionsInIdOrder();
}

View File

@ -8,10 +8,10 @@ using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>DI registration for the fail-fast quest catalog (NEO-113).</summary>
/// <summary>DI registration for the fail-fast quest catalog and registry (NEO-113, NEO-114).</summary>
public static class QuestCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="QuestDefinitionCatalog"/> as a singleton.</summary>
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="QuestDefinitionCatalog"/> and <see cref="IQuestDefinitionRegistry"/> as singletons.</summary>
public static IServiceCollection AddQuestDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
@ -58,6 +58,9 @@ public static class QuestCatalogServiceCollectionExtensions
logger);
});
services.AddSingleton<IQuestDefinitionRegistry>(sp =>
new QuestDefinitionRegistry(sp.GetRequiredService<QuestDefinitionCatalog>()));
return services;
}
}

View File

@ -0,0 +1,55 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Adapter over <see cref="QuestDefinitionCatalog"/> (NEO-114).</summary>
public sealed class QuestDefinitionRegistry(QuestDefinitionCatalog catalog) : IQuestDefinitionRegistry
{
private readonly IReadOnlyList<QuestDefRow> _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog);
/// <inheritdoc />
public bool TryGetDefinition(string? questId, [NotNullWhen(true)] out QuestDefRow? definition)
{
if (questId is null)
{
definition = null;
return false;
}
return catalog.TryGetQuest(questId, out definition);
}
/// <inheritdoc />
public bool TryNormalizeKnown(string? rawQuestId, [NotNullWhen(true)] out string normalized)
{
if (rawQuestId is null)
{
normalized = string.Empty;
return false;
}
normalized = rawQuestId.Trim().ToLowerInvariant();
if (normalized.Length == 0)
{
normalized = string.Empty;
return false;
}
return catalog.TryGetQuest(normalized, out _);
}
/// <inheritdoc />
public IReadOnlyList<QuestDefRow> GetDefinitionsInIdOrder() => _definitionsInIdOrder;
private static IReadOnlyList<QuestDefRow> BuildDefinitionsInIdOrder(QuestDefinitionCatalog catalog)
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<QuestDefRow>(ids.Length);
foreach (var id in ids)
{
list.Add(catalog.ById[id]);
}
return list;
}
}

View File

@ -146,7 +146,7 @@ On startup the host loads every **`*_quests.json`** under the quests directory *
**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**), and the three quest 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. **`IQuestDefinitionRegistry`** (NEO-114) will be the lookup surface for game code; until then the **`QuestDefinitionCatalog`** singleton is registered for fail-fast startup only—do not inject it in new game code.
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.
## Encounter definitions (NEO-103)