NEO-102: add encounter and reward-table definition registries + DI

Thin IEncounterDefinitionRegistry / IRewardTableDefinitionRegistry adapters
over NEO-101 catalogs with TryNormalizeKnown, DI registration, and 18 AAA tests.
pull/141/head
VinPropane 2026-05-31 00:38:38 -04:00
parent 93eca821d7
commit 734876cde9
14 changed files with 579 additions and 13 deletions

View File

@ -7,7 +7,7 @@
| **Module ID** | E5.M3 | | **Module ID** | E5.M3 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype | | **Stage target** | Prototype |
| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | | **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
| **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | | **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
## Purpose ## Purpose
@ -80,7 +80,9 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin
## Implementation snapshot (NEO-101) ## Implementation snapshot (NEO-101)
**Server catalog load landed ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders under [`server/NeonSprawl.Server/Game/Encounters/`](../../../server/NeonSprawl.Server/Game/Encounters/) — `RewardTableDefinitionCatalogLoader` (after item catalog) then `EncounterDefinitionCatalogLoader` (after reward tables); CI-parity E5M3 gates in C#; eager DI resolve in `Program.cs`. Config + discovery: [server README — Reward table catalog](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101), [Encounter catalog](../../../server/README.md#encounter-catalog-contentencounters-neo-101). Plan: [NEO-101 implementation plan](../../plans/NEO-101-implementation-plan.md). Injectable registries deferred to **NEO-102**. **Server catalog load landed ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders under [`server/NeonSprawl.Server/Game/Encounters/`](../../../server/NeonSprawl.Server/Game/Encounters/) — `RewardTableDefinitionCatalogLoader` (after item catalog) then `EncounterDefinitionCatalogLoader` (after reward tables); CI-parity E5M3 gates in C#; eager DI resolve in `Program.cs`. Config + discovery: [server README — Reward table catalog](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101), [Encounter catalog](../../../server/README.md#encounter-catalog-contentencounters-neo-101). Plan: [NEO-101 implementation plan](../../plans/NEO-101-implementation-plan.md).
**Injectable registries landed ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI in `AddEncounterAndRewardCatalogs`; game/HTTP callers should inject registries, not catalog types. Plan: [NEO-102 implementation plan](../../plans/NEO-102-implementation-plan.md).
## Source anchors ## Source anchors

File diff suppressed because one or more lines are too long

View File

@ -138,8 +138,10 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] Registries resolve frozen encounter + reward table ids. - [x] Registries resolve frozen encounter + reward table ids.
- [ ] Unknown id normalization fails closed. - [x] Unknown id normalization fails closed.
**Landed ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** injectable encounter + reward-table registries + DI; 18 AAA tests; plan [NEO-102-implementation-plan.md](NEO-102-implementation-plan.md).
--- ---

View File

@ -45,8 +45,16 @@ No clarifications needed — E5M3-03 backlog, [NEO-101 plan](NEO-101-implementat
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] Registries resolve frozen encounter + reward table ids (`prototype_combat_pocket`, `prototype_combat_pocket_clear`). - [x] Registries resolve frozen encounter + reward table ids (`prototype_combat_pocket`, `prototype_combat_pocket_clear`).
- [ ] Unknown id normalization fails closed (`TryNormalizeKnown` returns false; no throw). - [x] Unknown id normalization fails closed (`TryNormalizeKnown` returns false; no throw).
## Implementation reconciliation (shipped)
- **Interfaces:** `IEncounterDefinitionRegistry`, `IRewardTableDefinitionRegistry` with `TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`.
- **Adapters:** `EncounterDefinitionRegistry`, `RewardTableDefinitionRegistry` over NEO-101 catalogs (cached id-order lists).
- **DI:** `AddEncounterAndRewardCatalogs` registers both registries after catalog singletons; no `Program.cs` change.
- **Tests:** 18 AAA tests in `EncounterDefinitionRegistryTests` + `RewardTableDefinitionRegistryTests` (unit + host DI).
- **Docs:** `server/README.md` registry sections; alignment register E5.M3 row updated.
## Technical approach ## Technical approach

View File

@ -0,0 +1,205 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public class EncounterDefinitionRegistryTests
{
private const string PrototypeCombatPocket = "prototype_combat_pocket";
private static EncounterDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, EncounterDefRow> byId)
{
var catalog = new EncounterDefinitionCatalog("/tmp/encounters", byId, catalogJsonFileCount: 1);
return new EncounterDefinitionRegistry(catalog);
}
private static EncounterDefRow CreatePrototypeCombatPocketRow() =>
new(
PrototypeCombatPocket,
"Prototype Combat Pocket",
"defeat_all_targets",
["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"],
"prototype_combat_pocket_clear");
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenIdExists()
{
// Arrange
var rows = new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(PrototypeCombatPocket, out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal("Prototype Combat Pocket", def!.DisplayName);
Assert.Equal("defeat_all_targets", def.CompletionCriteriaKind);
Assert.Equal("prototype_combat_pocket_clear", def.RewardTableId);
Assert.Equal(3, def.RequiredNpcInstanceIds.Count);
Assert.Contains("prototype_npc_melee", def.RequiredNpcInstanceIds);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenEncounterIdIsNull()
{
// Arrange
var rows = new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(),
};
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, EncounterDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_encounter", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase()
{
// Arrange
var rows = new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown(" Prototype_Combat_Pocket ", out var normalized);
// Assert
Assert.True(found);
Assert.Equal(PrototypeCombatPocket, normalized);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull()
{
// Arrange
var rows = new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(),
};
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, EncounterDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(),
};
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, EncounterDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(),
};
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_WhenMultipleEncounters()
{
// Arrange
var rows = new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal)
{
["z_encounter"] = new EncounterDefRow(
"z_encounter",
"Z",
"defeat_all_targets",
["prototype_npc_melee"],
"prototype_combat_pocket_clear"),
[PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(),
["a_encounter"] = new EncounterDefRow(
"a_encounter",
"A",
"defeat_all_targets",
["prototype_npc_melee"],
"prototype_combat_pocket_clear"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(3, list.Count);
Assert.Equal("a_encounter", list[0].Id);
Assert.Equal(PrototypeCombatPocket, list[1].Id);
Assert.Equal("z_encounter", list[2].Id);
}
[Fact]
public async Task Host_ShouldResolveRegistriesFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var encounterRegistry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var rewardRegistry = factory.Services.GetRequiredService<IRewardTableDefinitionRegistry>();
var encounterFound = encounterRegistry.TryGetDefinition(PrototypeCombatPocket, out var encounter);
var unknownEncounter = encounterRegistry.TryGetDefinition("not_a_real_encounter", out var missingEncounter);
var normalizedEncounter = encounterRegistry.TryNormalizeKnown(" Prototype_Combat_Pocket ", out var encounterId);
var rewardFound = rewardRegistry.TryGetDefinition("prototype_combat_pocket_clear", out var rewardTable);
var unknownReward = rewardRegistry.TryNormalizeKnown("prototype_unknown", out var unknownRewardId);
var encounterList = encounterRegistry.GetDefinitionsInIdOrder();
// Assert
Assert.True(encounterFound);
Assert.NotNull(encounter);
Assert.Equal("prototype_combat_pocket_clear", encounter!.RewardTableId);
Assert.False(unknownEncounter);
Assert.Null(missingEncounter);
Assert.True(normalizedEncounter);
Assert.Equal(PrototypeCombatPocket, encounterId);
Assert.True(rewardFound);
Assert.NotNull(rewardTable);
Assert.Equal(10, rewardTable!.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity);
Assert.False(unknownReward);
Assert.Equal("prototype_unknown", unknownRewardId);
Assert.Single(encounterList);
Assert.Equal(PrototypeCombatPocket, encounterList[0].Id);
}
}

View File

@ -0,0 +1,187 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public class RewardTableDefinitionRegistryTests
{
private const string PrototypeCombatPocketClear = "prototype_combat_pocket_clear";
private static RewardTableDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, RewardTableDefRow> byId)
{
var catalog = new RewardTableDefinitionCatalog("/tmp/reward-tables", byId, catalogJsonFileCount: 1);
return new RewardTableDefinitionRegistry(catalog);
}
private static RewardTableDefRow CreatePrototypeCombatPocketClearRow() =>
new(
PrototypeCombatPocketClear,
"Prototype Combat Pocket Clear",
[
new RewardGrantRow("scrap_metal_bulk", 10),
new RewardGrantRow("contract_handoff_token", 1),
]);
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenIdExists()
{
// Arrange
var rows = new Dictionary<string, RewardTableDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(PrototypeCombatPocketClear, out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal("Prototype Combat Pocket Clear", def!.DisplayName);
Assert.Equal(10, def.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity);
Assert.Equal(1, def.FixedGrants.First(g => g.ItemId == "contract_handoff_token").Quantity);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenRewardTableIdIsNull()
{
// Arrange
var rows = new Dictionary<string, RewardTableDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(),
};
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, RewardTableDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_reward_table", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase()
{
// Arrange
var rows = new Dictionary<string, RewardTableDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown(" Prototype_Combat_Pocket_Clear ", out var normalized);
// Assert
Assert.True(found);
Assert.Equal(PrototypeCombatPocketClear, normalized);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull()
{
// Arrange
var rows = new Dictionary<string, RewardTableDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(),
};
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, RewardTableDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(),
};
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, RewardTableDefRow>(StringComparer.Ordinal)
{
[PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(),
};
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_WhenMultipleRewardTables()
{
// Arrange
var rows = new Dictionary<string, RewardTableDefRow>(StringComparer.Ordinal)
{
["z_table"] = new RewardTableDefRow("z_table", "Z", [new RewardGrantRow("scrap_metal_bulk", 1)]),
[PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(),
["a_table"] = new RewardTableDefRow("a_table", "A", [new RewardGrantRow("scrap_metal_bulk", 1)]),
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(3, list.Count);
Assert.Equal("a_table", list[0].Id);
Assert.Equal(PrototypeCombatPocketClear, list[1].Id);
Assert.Equal("z_table", 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<IRewardTableDefinitionRegistry>();
var found = registry.TryGetDefinition(PrototypeCombatPocketClear, out var rewardTable);
var unknown = registry.TryGetDefinition("not_a_real_reward_table", out var missing);
var normalized = registry.TryNormalizeKnown(" Prototype_Combat_Pocket_Clear ", out var rewardTableId);
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.True(found);
Assert.NotNull(rewardTable);
Assert.Equal(10, rewardTable!.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity);
Assert.Equal(1, rewardTable.FixedGrants.First(g => g.ItemId == "contract_handoff_token").Quantity);
Assert.False(unknown);
Assert.Null(missing);
Assert.True(normalized);
Assert.Equal(PrototypeCombatPocketClear, rewardTableId);
Assert.Single(list);
Assert.Equal(PrototypeCombatPocketClear, list[0].Id);
}
}

View File

@ -6,10 +6,10 @@ using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Encounters; namespace NeonSprawl.Server.Game.Encounters;
/// <summary>DI registration for the fail-fast encounter + reward-table catalogs (NEO-101).</summary> /// <summary>DI registration for the fail-fast encounter + reward-table catalogs and registries (NEO-101, NEO-102).</summary>
public static class EncounterCatalogServiceCollectionExtensions public static class EncounterCatalogServiceCollectionExtensions
{ {
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers encounter + reward-table catalog singletons.</summary> /// <summary>Binds <see cref="ContentPathsOptions"/> and registers encounter + reward-table catalog singletons and registries.</summary>
public static IServiceCollection AddEncounterAndRewardCatalogs(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddEncounterAndRewardCatalogs(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddOptions<ContentPathsOptions>() services.AddOptions<ContentPathsOptions>()
@ -70,6 +70,12 @@ public static class EncounterCatalogServiceCollectionExtensions
logger); logger);
}); });
services.AddSingleton<IEncounterDefinitionRegistry>(sp =>
new EncounterDefinitionRegistry(sp.GetRequiredService<EncounterDefinitionCatalog>()));
services.AddSingleton<IRewardTableDefinitionRegistry>(sp =>
new RewardTableDefinitionRegistry(sp.GetRequiredService<RewardTableDefinitionCatalog>()));
return services; return services;
} }
} }

View File

@ -2,7 +2,7 @@ using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Encounters; namespace NeonSprawl.Server.Game.Encounters;
/// <summary>In-memory encounter catalog loaded at startup (NEO-101). Game callers should use injectable registries (NEO-102).</summary> /// <summary>In-memory encounter catalog loaded at startup (NEO-101). Game callers should use <see cref="IEncounterDefinitionRegistry"/> (NEO-102).</summary>
public sealed class EncounterDefinitionCatalog( public sealed class EncounterDefinitionCatalog(
string encountersDirectory, string encountersDirectory,
IReadOnlyDictionary<string, EncounterDefRow> byId, IReadOnlyDictionary<string, EncounterDefRow> byId,

View File

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

View File

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Read-only access to validated <see cref="EncounterDefRow"/> entries loaded at startup (<see cref="EncounterDefinitionCatalog"/>).
/// </summary>
/// <remarks>
/// <para><b>E5.M3 (encounter progress / completion):</b> callers validating encounter ids and resolving completion metadata should depend on this interface
/// rather than <see cref="EncounterDefinitionCatalog"/> so encounter defs stay centralized.</para>
/// <para><b>NEO-103:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
/// </remarks>
public interface IEncounterDefinitionRegistry
{
/// <summary>Attempts to resolve an encounter by stable <c>id</c> (see <c>encounter-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? encounterId, [NotNullWhen(true)] out EncounterDefRow? definition);
/// <summary>Trims and lowercases <paramref name="rawEncounterId"/>; 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? rawEncounterId, [NotNullWhen(true)] out string normalized);
/// <summary>Every loaded definition, ordered by <see cref="EncounterDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<EncounterDefRow> GetDefinitionsInIdOrder();
}

View File

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Read-only access to validated <see cref="RewardTableDefRow"/> entries loaded at startup (<see cref="RewardTableDefinitionCatalog"/>).
/// </summary>
/// <remarks>
/// <para><b>E5.M3 (encounter completion / loot grants):</b> callers validating reward-table ids and resolving fixed grants should depend on this interface
/// rather than <see cref="RewardTableDefinitionCatalog"/> so reward tables stay centralized.</para>
/// <para><b>NEO-103:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
/// </remarks>
public interface IRewardTableDefinitionRegistry
{
/// <summary>Attempts to resolve a reward table by stable <c>id</c> (see <c>reward-table.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? rewardTableId, [NotNullWhen(true)] out RewardTableDefRow? definition);
/// <summary>Trims and lowercases <paramref name="rawRewardTableId"/>; 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? rawRewardTableId, [NotNullWhen(true)] out string normalized);
/// <summary>Every loaded definition, ordered by <see cref="RewardTableDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<RewardTableDefRow> GetDefinitionsInIdOrder();
}

View File

@ -2,7 +2,7 @@ using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Encounters; namespace NeonSprawl.Server.Game.Encounters;
/// <summary>In-memory reward-table catalog loaded at startup (NEO-101). Game callers should use injectable registries (NEO-102).</summary> /// <summary>In-memory reward-table catalog loaded at startup (NEO-101). Game callers should use <see cref="IRewardTableDefinitionRegistry"/> (NEO-102).</summary>
public sealed class RewardTableDefinitionCatalog( public sealed class RewardTableDefinitionCatalog(
string rewardTablesDirectory, string rewardTablesDirectory,
IReadOnlyDictionary<string, RewardTableDefRow> byId, IReadOnlyDictionary<string, RewardTableDefRow> byId,

View File

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

View File

@ -118,7 +118,7 @@ On startup the host loads every **`*_reward_tables.json`** under the reward-tabl
**Docker / CI:** include **`content/reward-tables`**, **`content/items`**, and the two reward schemas in the mounted **`content/`** tree; set **`Content__RewardTablesDirectory`** when layout differs. **Docker / CI:** include **`content/reward-tables`**, **`content/items`**, and the two reward schemas in the mounted **`content/`** tree; set **`Content__RewardTablesDirectory`** when layout differs.
On success, **Information** logs include the resolved reward-tables directory path, distinct reward-table count, and catalog file count. Injectable registries land in **NEO-102**; do not inject **`RewardTableDefinitionCatalog`** in new game code until then. On success, **Information** logs include the resolved reward-tables directory path, distinct reward-table count, and catalog file count. Game code should use **`IRewardTableDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`RewardTableDefinitionCatalog`** in new game code.
## Encounter catalog (`content/encounters`, NEO-101) ## Encounter catalog (`content/encounters`, NEO-101)
@ -131,7 +131,7 @@ On startup the host loads every **`*_encounters.json`** under the encounters dir
**Docker / CI:** include **`content/encounters`**, **`content/reward-tables`**, and **`encounter-def.schema.json`** in the mounted **`content/`** tree; set **`Content__EncountersDirectory`** when layout differs. **Docker / CI:** include **`content/encounters`**, **`content/reward-tables`**, and **`encounter-def.schema.json`** in the mounted **`content/`** tree; set **`Content__EncountersDirectory`** when layout differs.
On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Injectable registries land in **NEO-102**; do not inject **`EncounterDefinitionCatalog`** in new game code until then. On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Game code should use **`IEncounterDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`EncounterDefinitionCatalog`** in new game code.
## NPC behavior definitions (NEO-90) ## NPC behavior definitions (NEO-90)