Merge pull request #153 from ViPro-Technologies/NEO-114-e7m1-injectable-quest-definition-registry-di
NEO-114: Injectable quest definition registry + DIpull/157/head
commit
437894ccd1
|
|
@ -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
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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** |
|
||||
|
|
@ -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).
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue