NEO-101: Fail-fast encounter and reward-table catalog load at startup.
Adds Game/Encounters loaders mirroring validate_content.py E5M3 gates, DI registration with reward-tables-before-encounters load order, 19 AAA tests, and server README catalog sections.pull/140/head
parent
06c58998cc
commit
f921095fb0
File diff suppressed because one or more lines are too long
|
|
@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
|
||||
**E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92–NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)–[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
|
||||
|
||||
**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **Server load/runtime (NEO-101+) not started.**
|
||||
**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **Runtime/engine (NEO-102+) not started.**
|
||||
|
||||
### Epic 6 — PvP Security
|
||||
|
||||
|
|
|
|||
|
|
@ -117,10 +117,10 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules).
|
||||
- [ ] Tests cover at least one happy path and one malformed catalog rejection.
|
||||
- [x] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules).
|
||||
- [x] Tests cover at least one happy path and one malformed catalog rejection.
|
||||
|
||||
---
|
||||
**Landed ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast encounter + reward-table catalog loaders, DI registration, 19 AAA tests; plan [NEO-101-implementation-plan.md](NEO-101-implementation-plan.md).
|
||||
|
||||
### E5M3-03 — Injectable encounter/reward registries + DI
|
||||
|
||||
|
|
|
|||
|
|
@ -42,8 +42,17 @@
|
|||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules).
|
||||
- [ ] Tests cover at least one happy path and one malformed catalog rejection (loader + host boot per kickoff).
|
||||
- [x] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules).
|
||||
- [x] Tests cover at least one happy path and one malformed catalog rejection (loader + host boot per kickoff).
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Loaders:** `RewardTableDefinitionCatalogLoader`, `EncounterDefinitionCatalogLoader` under `Game/Encounters/` with CI-parity gates.
|
||||
- **Catalogs:** `RewardTableDefinitionCatalog`, `EncounterDefinitionCatalog` singletons; eager resolve in `Program.cs`.
|
||||
- **DI:** `AddEncounterAndRewardCatalogs` — reward tables load after item catalog; encounters load after reward tables.
|
||||
- **Config:** `ContentPathsOptions` encounter + reward-table path keys; `InMemoryWebApplicationFactory` pins repo paths.
|
||||
- **Tests:** 19 AAA tests in `RewardTableDefinitionCatalogLoaderTests` + `EncounterDefinitionCatalogLoaderTests` (loader + host boot).
|
||||
- **Docs:** `server/README.md` catalog sections; alignment register E5.M3 row updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
using NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
internal static class EncounterCatalogTestPaths
|
||||
{
|
||||
internal static string DiscoverRepoRewardTablesDirectory() =>
|
||||
RewardTableCatalogPathResolution.TryDiscoverRewardTablesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/reward-tables from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
|
||||
internal static string DiscoverRepoEncountersDirectory() =>
|
||||
EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
|
||||
internal static string DiscoverRepoRewardTableDefSchemaPath() =>
|
||||
RewardTableCatalogPathResolution.ResolveRewardTableDefSchemaPath(
|
||||
DiscoverRepoRewardTablesDirectory(),
|
||||
configuredSchemaPath: null,
|
||||
contentRootPath: string.Empty);
|
||||
|
||||
internal static string DiscoverRepoRewardGrantRowSchemaPath() =>
|
||||
RewardTableCatalogPathResolution.ResolveRewardGrantRowSchemaPath(
|
||||
DiscoverRepoRewardTablesDirectory(),
|
||||
configuredSchemaPath: null,
|
||||
contentRootPath: string.Empty);
|
||||
|
||||
internal static string DiscoverRepoEncounterDefSchemaPath() =>
|
||||
EncounterCatalogPathResolution.ResolveEncounterDefSchemaPath(
|
||||
DiscoverRepoEncountersDirectory(),
|
||||
configuredSchemaPath: null,
|
||||
contentRootPath: string.Empty);
|
||||
|
||||
internal static HashSet<string> Slice1KnownItemIds { get; } =
|
||||
[
|
||||
"scrap_metal_bulk",
|
||||
"refined_plate_stock",
|
||||
"field_stim_mk0",
|
||||
"survey_drone_kit",
|
||||
"contract_handoff_token",
|
||||
"prototype_armor_shell",
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
public class EncounterDefinitionCatalogLoaderTests
|
||||
{
|
||||
private const string ValidPrototypeCatalogJson =
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"encounters": [
|
||||
{
|
||||
"id": "prototype_combat_pocket",
|
||||
"displayName": "Prototype Combat Pocket",
|
||||
"completionCriteria": { "kind": "defeat_all_targets" },
|
||||
"requiredNpcInstanceIds": [
|
||||
"prototype_npc_melee",
|
||||
"prototype_npc_ranged",
|
||||
"prototype_npc_elite"
|
||||
],
|
||||
"rewardTableId": "prototype_combat_pocket_clear"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static readonly HashSet<string> KnownRewardTableIds =
|
||||
["prototype_combat_pocket_clear"];
|
||||
|
||||
private static (string Root, string EncountersDir, string SchemaPath) CreateTempContentLayout()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-encountercat-");
|
||||
var encountersDir = Path.Combine(root.FullName, "content", "encounters");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(encountersDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var schemaPath = Path.Combine(schemaDir, "encounter-def.schema.json");
|
||||
File.Copy(EncounterCatalogTestPaths.DiscoverRepoEncounterDefSchemaPath(), schemaPath, overwrite: true);
|
||||
return (root.FullName, encountersDir, schemaPath);
|
||||
}
|
||||
|
||||
private static void WriteCatalog(string encountersDir, string catalogJson) =>
|
||||
File.WriteAllText(Path.Combine(encountersDir, "prototype_encounters.json"), catalogJson, Encoding.UTF8);
|
||||
|
||||
private static EncounterDefinitionCatalog LoadCatalog(
|
||||
string encountersDir,
|
||||
string schemaPath,
|
||||
IReadOnlySet<string>? knownRewardTableIds = null) =>
|
||||
EncounterDefinitionCatalogLoader.Load(
|
||||
encountersDir,
|
||||
schemaPath,
|
||||
knownRewardTableIds ?? KnownRewardTableIds,
|
||||
NullLogger.Instance);
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||
{
|
||||
// Arrange
|
||||
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(encountersDir, ValidPrototypeCatalogJson);
|
||||
// Act
|
||||
var catalog = LoadCatalog(encountersDir, schemaPath);
|
||||
// Assert
|
||||
Assert.Equal(1, catalog.DistinctEncounterCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.TryGetEncounter("prototype_combat_pocket", out var encounter));
|
||||
Assert.NotNull(encounter);
|
||||
Assert.Equal("Prototype Combat Pocket", encounter!.DisplayName);
|
||||
Assert.Equal("defeat_all_targets", encounter.CompletionCriteriaKind);
|
||||
Assert.Equal("prototype_combat_pocket_clear", encounter.RewardTableId);
|
||||
Assert.Equal(3, encounter.RequiredNpcInstanceIds.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenEncountersIsNotArray()
|
||||
{
|
||||
// Arrange
|
||||
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(encountersDir, "bad_encounters.json"),
|
||||
"""{"schemaVersion": 1, "encounters": "nope"}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_encounters.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("expected top-level 'encounters' array", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
||||
{
|
||||
// Arrange
|
||||
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(encountersDir, "bad_encounters.json"),
|
||||
"""{"schemaVersion": 2, "encounters": []}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDuplicateEncounterIdAcrossFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(encountersDir, ValidPrototypeCatalogJson);
|
||||
File.WriteAllText(
|
||||
Path.Combine(encountersDir, "extra_encounters.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"encounters": [
|
||||
{
|
||||
"id": "prototype_combat_pocket",
|
||||
"displayName": "Duplicate Encounter",
|
||||
"completionCriteria": { "kind": "defeat_all_targets" },
|
||||
"requiredNpcInstanceIds": [
|
||||
"prototype_npc_melee",
|
||||
"prototype_npc_ranged",
|
||||
"prototype_npc_elite"
|
||||
],
|
||||
"rewardTableId": "prototype_combat_pocket_clear"
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("duplicate encounter id 'prototype_combat_pocket'", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenE5M3AllowlistMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(encountersDir, "prototype_encounters.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"encounters": [
|
||||
{
|
||||
"id": "wrong_encounter_id",
|
||||
"displayName": "Wrong Encounter",
|
||||
"completionCriteria": { "kind": "defeat_all_targets" },
|
||||
"requiredNpcInstanceIds": [
|
||||
"prototype_npc_melee",
|
||||
"prototype_npc_ranged",
|
||||
"prototype_npc_elite"
|
||||
],
|
||||
"rewardTableId": "prototype_combat_pocket_clear"
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E5M3 expects exactly encounter ids", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenRequiredNpcInstanceIdsMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(encountersDir, "prototype_encounters.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"encounters": [
|
||||
{
|
||||
"id": "prototype_combat_pocket",
|
||||
"displayName": "Prototype Combat Pocket",
|
||||
"completionCriteria": { "kind": "defeat_all_targets" },
|
||||
"requiredNpcInstanceIds": [
|
||||
"prototype_npc_melee",
|
||||
"prototype_npc_ranged",
|
||||
"prototype_npc_unknown"
|
||||
],
|
||||
"rewardTableId": "prototype_combat_pocket_clear"
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("requiredNpcInstanceIds must be exactly", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenRewardTableIdUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var (_, encountersDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(encountersDir, "prototype_encounters.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"encounters": [
|
||||
{
|
||||
"id": "prototype_combat_pocket",
|
||||
"displayName": "Prototype Combat Pocket",
|
||||
"completionCriteria": { "kind": "defeat_all_targets" },
|
||||
"requiredNpcInstanceIds": [
|
||||
"prototype_npc_melee",
|
||||
"prototype_npc_ranged",
|
||||
"prototype_npc_elite"
|
||||
],
|
||||
"rewardTableId": "missing_reward_table"
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("rewardTableId 'missing_reward_table' is not in reward table catalogs", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaFileMissing()
|
||||
{
|
||||
// Arrange
|
||||
var (_, encountersDir, _) = CreateTempContentLayout();
|
||||
WriteCatalog(encountersDir, ValidPrototypeCatalogJson);
|
||||
var missingSchema = Path.Combine(encountersDir, "missing-encounter-def.schema.json");
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(encountersDir, missingSchema));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveCatalogsFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.GetAsync("/health");
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var rewardCatalog = factory.Services.GetRequiredService<RewardTableDefinitionCatalog>();
|
||||
Assert.Equal(1, rewardCatalog.DistinctRewardTableCount);
|
||||
Assert.True(rewardCatalog.TryGetRewardTable("prototype_combat_pocket_clear", out var rewardTable));
|
||||
Assert.NotNull(rewardTable);
|
||||
Assert.Equal(10, rewardTable!.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity);
|
||||
var encounterCatalog = factory.Services.GetRequiredService<EncounterDefinitionCatalog>();
|
||||
Assert.Equal(1, encounterCatalog.DistinctEncounterCount);
|
||||
Assert.True(encounterCatalog.TryGetEncounter("prototype_combat_pocket", out var encounter));
|
||||
Assert.NotNull(encounter);
|
||||
Assert.Equal("prototype_combat_pocket_clear", encounter!.RewardTableId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_ShouldFailStartup_WhenEncountersDirectoryInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-encounters-" + Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(badDir);
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||
b.UseSetting("Content:EncountersDirectory", badDir));
|
||||
factory.CreateClient();
|
||||
});
|
||||
// Assert
|
||||
Assert.NotNull(ex);
|
||||
Assert.Contains("Encounter catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(badDir))
|
||||
Directory.Delete(badDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
public class RewardTableDefinitionCatalogLoaderTests
|
||||
{
|
||||
private const string ValidPrototypeCatalogJson =
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rewardTables": [
|
||||
{
|
||||
"id": "prototype_combat_pocket_clear",
|
||||
"displayName": "Prototype Combat Pocket Clear",
|
||||
"fixedGrants": [
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 10 },
|
||||
{ "itemId": "contract_handoff_token", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static (string Root, string RewardTablesDir, string TableSchemaPath, string GrantRowSchemaPath) CreateTempContentLayout()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-rewardtablecat-");
|
||||
var rewardTablesDir = Path.Combine(root.FullName, "content", "reward-tables");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(rewardTablesDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var tableSchemaPath = Path.Combine(schemaDir, "reward-table.schema.json");
|
||||
var grantRowSchemaPath = Path.Combine(schemaDir, "reward-grant-row.schema.json");
|
||||
File.Copy(EncounterCatalogTestPaths.DiscoverRepoRewardTableDefSchemaPath(), tableSchemaPath, overwrite: true);
|
||||
File.Copy(EncounterCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), grantRowSchemaPath, overwrite: true);
|
||||
return (root.FullName, rewardTablesDir, tableSchemaPath, grantRowSchemaPath);
|
||||
}
|
||||
|
||||
private static void WriteCatalog(string rewardTablesDir, string catalogJson) =>
|
||||
File.WriteAllText(Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), catalogJson, Encoding.UTF8);
|
||||
|
||||
private static RewardTableDefinitionCatalog LoadCatalog(
|
||||
string rewardTablesDir,
|
||||
string tableSchemaPath,
|
||||
string grantRowSchemaPath,
|
||||
IReadOnlySet<string>? knownItemIds = null) =>
|
||||
RewardTableDefinitionCatalogLoader.Load(
|
||||
rewardTablesDir,
|
||||
tableSchemaPath,
|
||||
grantRowSchemaPath,
|
||||
knownItemIds ?? EncounterCatalogTestPaths.Slice1KnownItemIds,
|
||||
NullLogger.Instance);
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||
{
|
||||
// Arrange
|
||||
var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(rewardTablesDir, ValidPrototypeCatalogJson);
|
||||
// Act
|
||||
var catalog = LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath);
|
||||
// Assert
|
||||
Assert.Equal(1, catalog.DistinctRewardTableCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.TryGetRewardTable("prototype_combat_pocket_clear", out var table));
|
||||
Assert.NotNull(table);
|
||||
Assert.Equal("Prototype Combat Pocket Clear", table!.DisplayName);
|
||||
Assert.Equal(2, table.FixedGrants.Count);
|
||||
Assert.Equal("scrap_metal_bulk", table.FixedGrants[0].ItemId);
|
||||
Assert.Equal(10, table.FixedGrants[0].Quantity);
|
||||
Assert.Equal("contract_handoff_token", table.FixedGrants[1].ItemId);
|
||||
Assert.Equal(1, table.FixedGrants[1].Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenRewardTablesIsNotArray()
|
||||
{
|
||||
// Arrange
|
||||
var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(rewardTablesDir, "bad_reward_tables.json"),
|
||||
"""{"schemaVersion": 1, "rewardTables": "nope"}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_reward_tables.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("expected top-level 'rewardTables' array", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
||||
{
|
||||
// Arrange
|
||||
var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(rewardTablesDir, "bad_reward_tables.json"),
|
||||
"""{"schemaVersion": 2, "rewardTables": []}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDuplicateRewardTableIdAcrossFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(rewardTablesDir, ValidPrototypeCatalogJson);
|
||||
File.WriteAllText(
|
||||
Path.Combine(rewardTablesDir, "extra_reward_tables.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rewardTables": [
|
||||
{
|
||||
"id": "prototype_combat_pocket_clear",
|
||||
"displayName": "Duplicate Table",
|
||||
"fixedGrants": [
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 10 },
|
||||
{ "itemId": "contract_handoff_token", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("duplicate reward table id 'prototype_combat_pocket_clear'", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenE5M3AllowlistMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(rewardTablesDir, "prototype_reward_tables.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rewardTables": [
|
||||
{
|
||||
"id": "wrong_reward_table_id",
|
||||
"displayName": "Wrong Table",
|
||||
"fixedGrants": [
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 10 },
|
||||
{ "itemId": "contract_handoff_token", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E5M3 expects exactly reward table ids", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenUnknownItemIdInFixedGrants()
|
||||
{
|
||||
// Arrange
|
||||
var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(rewardTablesDir, "prototype_reward_tables.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rewardTables": [
|
||||
{
|
||||
"id": "prototype_combat_pocket_clear",
|
||||
"displayName": "Prototype Combat Pocket Clear",
|
||||
"fixedGrants": [
|
||||
{ "itemId": "unknown_item_id", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("itemId 'unknown_item_id' is not in item catalogs", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDuplicateItemIdInFixedGrants()
|
||||
{
|
||||
// Arrange
|
||||
var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(rewardTablesDir, "prototype_reward_tables.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rewardTables": [
|
||||
{
|
||||
"id": "prototype_combat_pocket_clear",
|
||||
"displayName": "Prototype Combat Pocket Clear",
|
||||
"fixedGrants": [
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 5 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 5 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("duplicate itemId 'scrap_metal_bulk'", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenFrozenGrantQuantitiesMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(rewardTablesDir, "prototype_reward_tables.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rewardTables": [
|
||||
{
|
||||
"id": "prototype_combat_pocket_clear",
|
||||
"displayName": "Prototype Combat Pocket Clear",
|
||||
"fixedGrants": [
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 9 },
|
||||
{ "itemId": "contract_handoff_token", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype_combat_pocket_clear fixedGrants must match", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaFileMissing()
|
||||
{
|
||||
// Arrange
|
||||
var (_, rewardTablesDir, _, grantRowSchemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(rewardTablesDir, ValidPrototypeCatalogJson);
|
||||
var missingSchema = Path.Combine(rewardTablesDir, "missing-reward-table.schema.json");
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
LoadCatalog(rewardTablesDir, missingSchema, grantRowSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ using Microsoft.Extensions.Time.Testing;
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
|
|
@ -51,6 +52,12 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var rewardTablesDir = RewardTableCatalogPathResolution.TryDiscoverRewardTablesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/reward-tables from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var encountersDir = EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
|
|
@ -58,6 +65,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
|
||||
builder.UseSetting("Content:EncountersDirectory", encountersDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Resolves encounter catalog paths for local dev, tests, and container layouts (NEO-101).</summary>
|
||||
public static class EncounterCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/encounters</c> directory.</summary>
|
||||
public static string? TryDiscoverEncountersDirectory(string startDirectory)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "content", "encounters");
|
||||
if (Directory.Exists(candidate))
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the encounters catalog directory.
|
||||
/// Empty <paramref name="configuredEncountersDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
public static string ResolveEncountersDirectory(string? configuredEncountersDirectory, string contentRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredEncountersDirectory))
|
||||
{
|
||||
var discovered = TryDiscoverEncountersDirectory(AppContext.BaseDirectory);
|
||||
if (discovered is not null)
|
||||
return discovered;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Content:EncountersDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/encounters'). " +
|
||||
"Set Content:EncountersDirectory in configuration or environment (e.g. Content__EncountersDirectory).");
|
||||
}
|
||||
|
||||
var trimmed = configuredEncountersDirectory.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a single encounter row (Draft 2020-12).</summary>
|
||||
public static string ResolveEncounterDefSchemaPath(
|
||||
string encountersDirectory,
|
||||
string? configuredSchemaPath,
|
||||
string contentRootPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||
{
|
||||
var trimmed = configuredSchemaPath.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(encountersDirectory, "..", "schemas", "encounter-def.schema.json"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>DI registration for the fail-fast encounter + reward-table catalogs (NEO-101).</summary>
|
||||
public static class EncounterCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers encounter + reward-table catalog singletons.</summary>
|
||||
public static IServiceCollection AddEncounterAndRewardCatalogs(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||
|
||||
services.AddSingleton<RewardTableDefinitionCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("NeonSprawl.Server.Game.Encounters.RewardTableCatalog");
|
||||
|
||||
var rewardTablesDir = RewardTableCatalogPathResolution.ResolveRewardTablesDirectory(
|
||||
opts.RewardTablesDirectory,
|
||||
hostEnv.ContentRootPath);
|
||||
var rewardTableSchemaPath = RewardTableCatalogPathResolution.ResolveRewardTableDefSchemaPath(
|
||||
rewardTablesDir,
|
||||
opts.RewardTableDefSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
var rewardGrantRowSchemaPath = RewardTableCatalogPathResolution.ResolveRewardGrantRowSchemaPath(
|
||||
rewardTablesDir,
|
||||
opts.RewardGrantRowSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
|
||||
var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return RewardTableDefinitionCatalogLoader.Load(
|
||||
rewardTablesDir,
|
||||
rewardTableSchemaPath,
|
||||
rewardGrantRowSchemaPath,
|
||||
knownItemIds,
|
||||
logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<EncounterDefinitionCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var rewardTableCatalog = sp.GetRequiredService<RewardTableDefinitionCatalog>();
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("NeonSprawl.Server.Game.Encounters.EncounterCatalog");
|
||||
|
||||
var encountersDir = EncounterCatalogPathResolution.ResolveEncountersDirectory(
|
||||
opts.EncountersDirectory,
|
||||
hostEnv.ContentRootPath);
|
||||
var encounterDefSchemaPath = EncounterCatalogPathResolution.ResolveEncounterDefSchemaPath(
|
||||
encountersDir,
|
||||
opts.EncounterDefSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
|
||||
var knownRewardTableIds = rewardTableCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return EncounterDefinitionCatalogLoader.Load(
|
||||
encountersDir,
|
||||
encounterDefSchemaPath,
|
||||
knownRewardTableIds,
|
||||
logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Load-time encounter row (NEO-101).</summary>
|
||||
public sealed class EncounterDefRow(
|
||||
string id,
|
||||
string displayName,
|
||||
string completionCriteriaKind,
|
||||
IReadOnlyList<string> requiredNpcInstanceIds,
|
||||
string rewardTableId)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
|
||||
public string DisplayName { get; } = displayName;
|
||||
|
||||
public string CompletionCriteriaKind { get; } = completionCriteriaKind;
|
||||
|
||||
public IReadOnlyList<string> RequiredNpcInstanceIds { get; } = requiredNpcInstanceIds;
|
||||
|
||||
public string RewardTableId { get; } = rewardTableId;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>In-memory encounter catalog loaded at startup (NEO-101). Game callers should use injectable registries (NEO-102).</summary>
|
||||
public sealed class EncounterDefinitionCatalog(
|
||||
string encountersDirectory,
|
||||
IReadOnlyDictionary<string, EncounterDefRow> byId,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
/// <summary>Absolute path to the directory that was enumerated for <c>*_encounters.json</c> catalogs.</summary>
|
||||
public string EncountersDirectory { get; } = encountersDirectory;
|
||||
|
||||
public IReadOnlyDictionary<string, EncounterDefRow> ById { get; } =
|
||||
new ReadOnlyDictionary<string, EncounterDefRow>(new Dictionary<string, EncounterDefRow>(byId, StringComparer.Ordinal));
|
||||
|
||||
public int DistinctEncounterCount => ById.Count;
|
||||
|
||||
/// <summary>Number of <c>*_encounters.json</c> files under <see cref="EncountersDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
|
||||
|
||||
/// <summary>Resolves a catalog row by stable encounter <paramref name="id"/>.</summary>
|
||||
public bool TryGetEncounter(string id, out EncounterDefRow? row) =>
|
||||
ById.TryGetValue(id, out row);
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Loads and validates <c>content/encounters/*_encounters.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-101).</summary>
|
||||
public static class EncounterDefinitionCatalogLoader
|
||||
{
|
||||
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||
public static EncounterDefinitionCatalog Load(
|
||||
string encountersDirectory,
|
||||
string encounterDefSchemaPath,
|
||||
IReadOnlySet<string> knownRewardTableIds,
|
||||
ILogger logger)
|
||||
{
|
||||
encountersDirectory = Path.GetFullPath(encountersDirectory);
|
||||
encounterDefSchemaPath = Path.GetFullPath(encounterDefSchemaPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(encounterDefSchemaPath))
|
||||
errors.Add($"error: missing schema file {encounterDefSchemaPath}");
|
||||
|
||||
if (!Directory.Exists(encountersDirectory))
|
||||
errors.Add($"error: missing directory {encountersDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(encountersDirectory, "*_encounters.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_encounters.json files under {encountersDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var schema = JsonSchema.FromText(File.ReadAllText(encounterDefSchemaPath));
|
||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||
|
||||
var encounterIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var rows = new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var path in jsonFiles)
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(path));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root is not JsonObject rootObj)
|
||||
{
|
||||
errors.Add($"error: {path}: expected JSON object at root");
|
||||
continue;
|
||||
}
|
||||
|
||||
var schemaVersionNode = rootObj["schemaVersion"];
|
||||
if (schemaVersionNode is not JsonValue schemaVersionValue ||
|
||||
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
|
||||
schemaVersion != 1)
|
||||
{
|
||||
var got = schemaVersionNode?.ToJsonString() ?? "null";
|
||||
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var encountersNode = rootObj["encounters"];
|
||||
if (encountersNode is not JsonArray encountersArray)
|
||||
{
|
||||
errors.Add($"error: {path}: expected top-level 'encounters' array");
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < encountersArray.Count; i++)
|
||||
{
|
||||
var encounter = encountersArray[i];
|
||||
if (encounter is not JsonObject rowObj)
|
||||
{
|
||||
errors.Add($"error: {path}: encounters[{i}] must be an object");
|
||||
continue;
|
||||
}
|
||||
|
||||
var eval = schema.Evaluate(rowObj, evalOptions);
|
||||
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||
if (!eval.IsValid)
|
||||
{
|
||||
if (schemaMsgs.Count == 0)
|
||||
schemaMsgs.Add($"error: {path} encounters[{i}] (root): schema validation failed");
|
||||
|
||||
errors.AddRange(schemaMsgs);
|
||||
}
|
||||
|
||||
var rowSchemaErrors = schemaMsgs.Count;
|
||||
|
||||
var eid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||
if (eid is not null && rowSchemaErrors == 0)
|
||||
{
|
||||
if (encounterIdToSourceFile.TryGetValue(eid, out var prevPath))
|
||||
{
|
||||
errors.Add($"error: duplicate encounter id '{eid}' in {prevPath} and {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
encounterIdToSourceFile[eid] = path;
|
||||
|
||||
var rewardTableId = (rowObj["rewardTableId"] as JsonValue)?.GetValue<string>();
|
||||
if (rewardTableId is not null && !knownRewardTableIds.Contains(rewardTableId))
|
||||
{
|
||||
errors.Add(
|
||||
$"error: {path} encounters[{i}]: rewardTableId '{rewardTableId}' is not in reward table catalogs");
|
||||
}
|
||||
|
||||
rows[eid] = ParseRow(rowObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var e5m3 = PrototypeE5M3EncounterCatalogRules.TryGetE5M3GateError(encounterIdToSourceFile);
|
||||
if (e5m3 is not null)
|
||||
{
|
||||
errors.Add(e5m3);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
var npcCrossRef = PrototypeE5M3EncounterCatalogRules.TryGetNpcInstanceCrossRefError(rows);
|
||||
if (npcCrossRef is not null)
|
||||
{
|
||||
errors.Add(npcCrossRef);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Loaded encounter catalog from {EncountersDirectory}: {EncounterCount} encounter(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||
encountersDirectory,
|
||||
rows.Count,
|
||||
jsonFiles.Length);
|
||||
}
|
||||
|
||||
return new EncounterDefinitionCatalog(encountersDirectory, rows, jsonFiles.Length);
|
||||
}
|
||||
|
||||
private static EncounterDefRow ParseRow(JsonObject rowObj)
|
||||
{
|
||||
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||
var completionCriteria = rowObj["completionCriteria"] as JsonObject
|
||||
?? throw new InvalidOperationException("expected completionCriteria object");
|
||||
var completionCriteriaKind = (completionCriteria["kind"] as JsonValue)!.GetValue<string>();
|
||||
var requiredNpcInstanceIds = ParseStringArray(rowObj["requiredNpcInstanceIds"] as JsonArray);
|
||||
var rewardTableId = (rowObj["rewardTableId"] as JsonValue)!.GetValue<string>();
|
||||
|
||||
return new EncounterDefRow(id, displayName, completionCriteriaKind, requiredNpcInstanceIds, rewardTableId);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseStringArray(JsonArray? array)
|
||||
{
|
||||
if (array is null)
|
||||
return [];
|
||||
|
||||
var values = new List<string>(array.Count);
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is JsonValue value && value.TryGetValue<string>(out var s))
|
||||
values.Add(s);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||
{
|
||||
var sink = new List<string>();
|
||||
AppendSchemaMessages(eval, filePath, index, sink);
|
||||
return sink;
|
||||
}
|
||||
|
||||
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
|
||||
{
|
||||
if (r.HasDetails)
|
||||
{
|
||||
foreach (var d in r.Details!)
|
||||
AppendSchemaMessages(d, filePath, index, sink);
|
||||
}
|
||||
|
||||
if (!r.HasErrors)
|
||||
return;
|
||||
|
||||
foreach (var kv in r.Errors!)
|
||||
{
|
||||
var loc = r.InstanceLocation?.ToString();
|
||||
if (string.IsNullOrEmpty(loc) || loc == "#")
|
||||
loc = "(root)";
|
||||
|
||||
sink.Add($"error: {filePath} encounters[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfAny(List<string> errors)
|
||||
{
|
||||
if (errors.Count == 0)
|
||||
return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Encounter catalog validation failed:");
|
||||
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||
sb.AppendLine(e);
|
||||
|
||||
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
using System.Collections.Frozen;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E5M3 encounter roster + cross-ref gates (NEO-101), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_ENCOUNTER_IDS</c>,
|
||||
/// <c>PROTOTYPE_E5M2_NPC_INSTANCE_IDS</c>, <c>_prototype_e5m3_encounter_gate</c>, and
|
||||
/// <c>_prototype_e5m3_encounter_cross_ref_gate</c>.
|
||||
/// </summary>
|
||||
public static class PrototypeE5M3EncounterCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_ENCOUNTER_IDS</c>.</summary>
|
||||
public static readonly FrozenSet<string> ExpectedEncounterIds = FrozenSet.ToFrozenSet(
|
||||
["prototype_combat_pocket"],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M2_NPC_INSTANCE_IDS</c>.</summary>
|
||||
public static FrozenSet<string> ExpectedNpcInstanceIds =>
|
||||
PrototypeNpcRegistry.GetInstanceIdsInOrder().ToFrozenSet(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a human-readable error if the E5M3 encounter contract fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetE5M3GateError(IReadOnlyDictionary<string, string> encounterIdToSourceFile)
|
||||
{
|
||||
var ids = encounterIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!ids.SetEquals(ExpectedEncounterIds))
|
||||
{
|
||||
return
|
||||
"error: prototype E5M3 expects exactly encounter ids " +
|
||||
$"[{string.Join(", ", ExpectedEncounterIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error when NPC instance cross-refs fail, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetNpcInstanceCrossRefError(IReadOnlyDictionary<string, EncounterDefRow> rowsById)
|
||||
{
|
||||
foreach (var (encounterId, row) in rowsById)
|
||||
{
|
||||
var npcSet = row.RequiredNpcInstanceIds.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!npcSet.SetEquals(ExpectedNpcInstanceIds))
|
||||
{
|
||||
return
|
||||
$"error: encounter '{encounterId}': requiredNpcInstanceIds must be exactly " +
|
||||
$"[{string.Join(", ", ExpectedNpcInstanceIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", npcSet.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E5M3 reward-table roster + grant quantity gates (NEO-101), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_REWARD_TABLE_IDS</c>,
|
||||
/// <c>PROTOTYPE_E5M3_REWARD_GRANTS</c>, <c>_prototype_e5m3_reward_table_gate</c>, and
|
||||
/// <c>_prototype_e5m3_reward_grant_content_gate</c>.
|
||||
/// </summary>
|
||||
public static class PrototypeE5M3RewardTableCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_REWARD_TABLE_IDS</c>.</summary>
|
||||
public static readonly FrozenSet<string> ExpectedRewardTableIds = FrozenSet.ToFrozenSet(
|
||||
["prototype_combat_pocket_clear"],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_REWARD_GRANTS</c>.</summary>
|
||||
public static readonly FrozenDictionary<string, int> ExpectedGrantQuantities =
|
||||
FrozenDictionary.ToFrozenDictionary(
|
||||
new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["scrap_metal_bulk"] = 10,
|
||||
["contract_handoff_token"] = 1,
|
||||
},
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a human-readable error if the E5M3 reward-table contract fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetE5M3GateError(IReadOnlyDictionary<string, string> rewardTableIdToSourceFile)
|
||||
{
|
||||
var ids = rewardTableIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!ids.SetEquals(ExpectedRewardTableIds))
|
||||
{
|
||||
return
|
||||
"error: prototype E5M3 expects exactly reward table ids " +
|
||||
$"[{string.Join(", ", ExpectedRewardTableIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error when frozen grant quantities fail, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetGrantContentGateError(IReadOnlyDictionary<string, RewardTableDefRow> rowsById)
|
||||
{
|
||||
if (!rowsById.TryGetValue("prototype_combat_pocket_clear", out var row))
|
||||
return null;
|
||||
|
||||
var grants = row.FixedGrants.ToDictionary(g => g.ItemId, g => g.Quantity, StringComparer.Ordinal);
|
||||
var expectedOrdered = ExpectedGrantQuantities.OrderBy(kv => kv.Key, StringComparer.Ordinal).ToList();
|
||||
var actualOrdered = grants.OrderBy(kv => kv.Key, StringComparer.Ordinal).ToList();
|
||||
if (grants.Count != ExpectedGrantQuantities.Count ||
|
||||
!actualOrdered.SequenceEqual(expectedOrdered))
|
||||
{
|
||||
return
|
||||
"error: prototype_combat_pocket_clear fixedGrants must match " +
|
||||
$"[{string.Join(", ", expectedOrdered.Select(kv => $"'{kv.Key}': {kv.Value}"))}], " +
|
||||
$"got [{string.Join(", ", actualOrdered.Select(kv => $"'{kv.Key}': {kv.Value}"))}]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>A single deterministic item grant on a <see cref="RewardTableDefRow"/> (NEO-101).</summary>
|
||||
public readonly record struct RewardGrantRow(string ItemId, int Quantity);
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Resolves reward-table catalog paths for local dev, tests, and container layouts (NEO-101).</summary>
|
||||
public static class RewardTableCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/reward-tables</c> directory.</summary>
|
||||
public static string? TryDiscoverRewardTablesDirectory(string startDirectory)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "content", "reward-tables");
|
||||
if (Directory.Exists(candidate))
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the reward-tables catalog directory.
|
||||
/// Empty <paramref name="configuredRewardTablesDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
public static string ResolveRewardTablesDirectory(string? configuredRewardTablesDirectory, string contentRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredRewardTablesDirectory))
|
||||
{
|
||||
var discovered = TryDiscoverRewardTablesDirectory(AppContext.BaseDirectory);
|
||||
if (discovered is not null)
|
||||
return discovered;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Content:RewardTablesDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/reward-tables'). " +
|
||||
"Set Content:RewardTablesDirectory in configuration or environment (e.g. Content__RewardTablesDirectory).");
|
||||
}
|
||||
|
||||
var trimmed = configuredRewardTablesDirectory.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a single reward-table row (Draft 2020-12).</summary>
|
||||
public static string ResolveRewardTableDefSchemaPath(
|
||||
string rewardTablesDirectory,
|
||||
string? configuredSchemaPath,
|
||||
string contentRootPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||
{
|
||||
var trimmed = configuredSchemaPath.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(rewardTablesDirectory, "..", "schemas", "reward-table.schema.json"));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a reward grant row referenced by reward-table schema (Draft 2020-12).</summary>
|
||||
public static string ResolveRewardGrantRowSchemaPath(
|
||||
string rewardTablesDirectory,
|
||||
string? configuredSchemaPath,
|
||||
string contentRootPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||
{
|
||||
var trimmed = configuredSchemaPath.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(rewardTablesDirectory, "..", "schemas", "reward-grant-row.schema.json"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Load-time reward table row (NEO-101).</summary>
|
||||
public sealed class RewardTableDefRow(
|
||||
string id,
|
||||
string displayName,
|
||||
IReadOnlyList<RewardGrantRow> fixedGrants)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
|
||||
public string DisplayName { get; } = displayName;
|
||||
|
||||
public IReadOnlyList<RewardGrantRow> FixedGrants { get; } = fixedGrants;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
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>
|
||||
public sealed class RewardTableDefinitionCatalog(
|
||||
string rewardTablesDirectory,
|
||||
IReadOnlyDictionary<string, RewardTableDefRow> byId,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
/// <summary>Absolute path to the directory that was enumerated for <c>*_reward_tables.json</c> catalogs.</summary>
|
||||
public string RewardTablesDirectory { get; } = rewardTablesDirectory;
|
||||
|
||||
public IReadOnlyDictionary<string, RewardTableDefRow> ById { get; } =
|
||||
new ReadOnlyDictionary<string, RewardTableDefRow>(new Dictionary<string, RewardTableDefRow>(byId, StringComparer.Ordinal));
|
||||
|
||||
public int DistinctRewardTableCount => ById.Count;
|
||||
|
||||
/// <summary>Number of <c>*_reward_tables.json</c> files under <see cref="RewardTablesDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
|
||||
|
||||
/// <summary>Resolves a catalog row by stable reward-table <paramref name="id"/>.</summary>
|
||||
public bool TryGetRewardTable(string id, out RewardTableDefRow? row) =>
|
||||
ById.TryGetValue(id, out row);
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Loads and validates <c>content/reward-tables/*_reward_tables.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-101).</summary>
|
||||
public static class RewardTableDefinitionCatalogLoader
|
||||
{
|
||||
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||
public static RewardTableDefinitionCatalog Load(
|
||||
string rewardTablesDirectory,
|
||||
string rewardTableSchemaPath,
|
||||
string rewardGrantRowSchemaPath,
|
||||
IReadOnlySet<string> knownItemIds,
|
||||
ILogger logger)
|
||||
{
|
||||
rewardTablesDirectory = Path.GetFullPath(rewardTablesDirectory);
|
||||
rewardTableSchemaPath = Path.GetFullPath(rewardTableSchemaPath);
|
||||
rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(rewardTableSchemaPath))
|
||||
errors.Add($"error: missing schema file {rewardTableSchemaPath}");
|
||||
|
||||
if (!File.Exists(rewardGrantRowSchemaPath))
|
||||
errors.Add($"error: missing schema file {rewardGrantRowSchemaPath}");
|
||||
|
||||
if (!Directory.Exists(rewardTablesDirectory))
|
||||
errors.Add($"error: missing directory {rewardTablesDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(rewardTablesDirectory, "*_reward_tables.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_reward_tables.json files under {rewardTablesDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var grantRowSchema = JsonSchema.FromText(File.ReadAllText(rewardGrantRowSchemaPath));
|
||||
SchemaRegistry.Global.Register(grantRowSchema);
|
||||
var tableSchema = JsonSchema.FromText(File.ReadAllText(rewardTableSchemaPath));
|
||||
SchemaRegistry.Global.Register(tableSchema);
|
||||
|
||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||
|
||||
var rewardTableIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var rows = new Dictionary<string, RewardTableDefRow>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var path in jsonFiles)
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(path));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root is not JsonObject rootObj)
|
||||
{
|
||||
errors.Add($"error: {path}: expected JSON object at root");
|
||||
continue;
|
||||
}
|
||||
|
||||
var schemaVersionNode = rootObj["schemaVersion"];
|
||||
if (schemaVersionNode is not JsonValue schemaVersionValue ||
|
||||
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
|
||||
schemaVersion != 1)
|
||||
{
|
||||
var got = schemaVersionNode?.ToJsonString() ?? "null";
|
||||
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var rewardTablesNode = rootObj["rewardTables"];
|
||||
if (rewardTablesNode is not JsonArray rewardTablesArray)
|
||||
{
|
||||
errors.Add($"error: {path}: expected top-level 'rewardTables' array");
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < rewardTablesArray.Count; i++)
|
||||
{
|
||||
var rewardTable = rewardTablesArray[i];
|
||||
if (rewardTable is not JsonObject rowObj)
|
||||
{
|
||||
errors.Add($"error: {path}: rewardTables[{i}] must be an object");
|
||||
continue;
|
||||
}
|
||||
|
||||
var eval = tableSchema.Evaluate(rowObj, evalOptions);
|
||||
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||
if (!eval.IsValid)
|
||||
{
|
||||
if (schemaMsgs.Count == 0)
|
||||
schemaMsgs.Add($"error: {path} rewardTables[{i}] (root): schema validation failed");
|
||||
|
||||
errors.AddRange(schemaMsgs);
|
||||
}
|
||||
|
||||
var rowSchemaErrors = schemaMsgs.Count;
|
||||
|
||||
var rid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||
if (rid is not null && rowSchemaErrors == 0)
|
||||
{
|
||||
if (rewardTableIdToSourceFile.TryGetValue(rid, out var prevPath))
|
||||
{
|
||||
errors.Add($"error: duplicate reward table id '{rid}' in {prevPath} and {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
rewardTableIdToSourceFile[rid] = path;
|
||||
CrossCheckFixedGrants(rowObj, path, i, knownItemIds, errors);
|
||||
rows[rid] = ParseRow(rowObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var e5m3 = PrototypeE5M3RewardTableCatalogRules.TryGetE5M3GateError(rewardTableIdToSourceFile);
|
||||
if (e5m3 is not null)
|
||||
{
|
||||
errors.Add(e5m3);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
var grantContent = PrototypeE5M3RewardTableCatalogRules.TryGetGrantContentGateError(rows);
|
||||
if (grantContent is not null)
|
||||
{
|
||||
errors.Add(grantContent);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Loaded reward table catalog from {RewardTablesDirectory}: {RewardTableCount} reward table(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||
rewardTablesDirectory,
|
||||
rows.Count,
|
||||
jsonFiles.Length);
|
||||
}
|
||||
|
||||
return new RewardTableDefinitionCatalog(rewardTablesDirectory, rows, jsonFiles.Length);
|
||||
}
|
||||
|
||||
private static void CrossCheckFixedGrants(
|
||||
JsonObject rowObj,
|
||||
string path,
|
||||
int tableIndex,
|
||||
IReadOnlySet<string> knownItemIds,
|
||||
List<string> errors)
|
||||
{
|
||||
if (rowObj["fixedGrants"] is not JsonArray grantsArray)
|
||||
return;
|
||||
|
||||
var seenGrantItemIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var j = 0; j < grantsArray.Count; j++)
|
||||
{
|
||||
if (grantsArray[j] is not JsonObject grantObj)
|
||||
continue;
|
||||
|
||||
var itemId = (grantObj["itemId"] as JsonValue)?.GetValue<string>();
|
||||
if (itemId is null)
|
||||
continue;
|
||||
|
||||
if (!seenGrantItemIds.Add(itemId))
|
||||
{
|
||||
errors.Add(
|
||||
$"error: {path} rewardTables[{tableIndex}].fixedGrants[{j}]: duplicate itemId '{itemId}'");
|
||||
}
|
||||
|
||||
if (!knownItemIds.Contains(itemId))
|
||||
{
|
||||
errors.Add(
|
||||
$"error: {path} rewardTables[{tableIndex}].fixedGrants[{j}]: itemId '{itemId}' is not in item catalogs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RewardTableDefRow ParseRow(JsonObject rowObj)
|
||||
{
|
||||
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||
var fixedGrants = ParseGrantRows(rowObj["fixedGrants"] as JsonArray);
|
||||
|
||||
return new RewardTableDefRow(id, displayName, fixedGrants);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RewardGrantRow> ParseGrantRows(JsonArray? array)
|
||||
{
|
||||
if (array is null)
|
||||
return [];
|
||||
|
||||
var rows = new List<RewardGrantRow>(array.Count);
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject grantObj)
|
||||
continue;
|
||||
|
||||
var itemId = (grantObj["itemId"] as JsonValue)!.GetValue<string>();
|
||||
var quantity = (grantObj["quantity"] as JsonValue)!.GetValue<int>();
|
||||
rows.Add(new RewardGrantRow(itemId, quantity));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||
{
|
||||
var sink = new List<string>();
|
||||
AppendSchemaMessages(eval, filePath, index, sink);
|
||||
return sink;
|
||||
}
|
||||
|
||||
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
|
||||
{
|
||||
if (r.HasDetails)
|
||||
{
|
||||
foreach (var d in r.Details!)
|
||||
AppendSchemaMessages(d, filePath, index, sink);
|
||||
}
|
||||
|
||||
if (!r.HasErrors)
|
||||
return;
|
||||
|
||||
foreach (var kv in r.Errors!)
|
||||
{
|
||||
var loc = r.InstanceLocation?.ToString();
|
||||
if (string.IsNullOrEmpty(loc) || loc == "#")
|
||||
loc = "(root)";
|
||||
|
||||
sink.Add($"error: {filePath} rewardTables[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfAny(List<string> errors)
|
||||
{
|
||||
if (errors.Count == 0)
|
||||
return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Reward table catalog validation failed:");
|
||||
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||
sb.AppendLine(e);
|
||||
|
||||
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||
}
|
||||
}
|
||||
|
|
@ -100,4 +100,34 @@ public sealed class ContentPathsOptions
|
|||
/// When unset, resolved as <c>{parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? NpcBehaviorDefSchemaPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/reward-tables</c> directory.
|
||||
/// </summary>
|
||||
public string? RewardTablesDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>reward-table.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of reward-tables directory}/schemas/reward-table.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? RewardTableDefSchemaPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>reward-grant-row.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of reward-tables directory}/schemas/reward-grant-row.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? RewardGrantRowSchemaPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/encounters</c> directory.
|
||||
/// </summary>
|
||||
public string? EncountersDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>encounter-def.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of encounters directory}/schemas/encounter-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? EncounterDefSchemaPath { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.Interaction;
|
||||
|
|
@ -25,6 +26,7 @@ builder.Services.AddResourceNodeCatalog(builder.Configuration);
|
|||
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
|
||||
builder.Services.AddThreatStateStore();
|
||||
builder.Services.AddNpcRuntimeStateStore();
|
||||
builder.Services.AddPlayerCombatHealthStore();
|
||||
|
|
@ -37,6 +39,8 @@ _ = app.Services.GetRequiredService<ResourceNodeCatalog>();
|
|||
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<AbilityDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<EncounterDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@
|
|||
"AbilitiesDirectory": "",
|
||||
"AbilityDefSchemaPath": "",
|
||||
"NpcBehaviorsDirectory": "",
|
||||
"NpcBehaviorDefSchemaPath": ""
|
||||
"NpcBehaviorDefSchemaPath": "",
|
||||
"RewardTablesDirectory": "",
|
||||
"RewardTableDefSchemaPath": "",
|
||||
"RewardGrantRowSchemaPath": "",
|
||||
"EncountersDirectory": "",
|
||||
"EncounterDefSchemaPath": ""
|
||||
},
|
||||
"Game": {
|
||||
"DevPlayerId": "dev-local-1",
|
||||
|
|
|
|||
|
|
@ -106,6 +106,33 @@ On startup the host loads every **`*_npc_behaviors.json`** under the npc-behavio
|
|||
|
||||
On success, **Information** logs include the resolved npc-behaviors directory path, distinct behavior count, and catalog file count. Game code should use **`INpcBehaviorDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-89). The catalog singleton remains for fail-fast startup only; do not inject **`NpcBehaviorDefinitionCatalog`** in new game code. Stable prototype behavior id constants: **`PrototypeNpcBehaviorRegistry`**.
|
||||
|
||||
## Reward table catalog (`content/reward-tables`, NEO-101)
|
||||
|
||||
On startup the host loads every **`*_reward_tables.json`** under the reward-tables directory, validates each row against **`content/schemas/reward-table.schema.json`** (with **`reward-grant-row.schema.json`** for grant `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate reward-table `id`** values across files, cross-checks every **`fixedGrants[].itemId`** against the **item catalog** (loaded first), rejects duplicate **`itemId`** within a table, and enforces the **prototype E5M3** one-table roster + frozen grant quantity gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||||
|
||||
| Config | Meaning |
|
||||
|--------|---------|
|
||||
| **`Content:RewardTablesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/reward-tables`**. |
|
||||
| **`Content:RewardTableDefSchemaPath`** | Optional override for **`reward-table.schema.json`**. When unset, **`{parent of reward-tables directory}/schemas/reward-table.schema.json`**. |
|
||||
| **`Content:RewardGrantRowSchemaPath`** | Optional override for **`reward-grant-row.schema.json`**. When unset, **`{parent of reward-tables directory}/schemas/reward-grant-row.schema.json`**. |
|
||||
|
||||
**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.
|
||||
|
||||
## Encounter catalog (`content/encounters`, NEO-101)
|
||||
|
||||
On startup the host loads every **`*_encounters.json`** under the encounters directory **after** the reward-table catalog, validates each row against **`content/schemas/encounter-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate encounter `id`** values across files, cross-checks each **`rewardTableId`** against loaded reward-table ids and each **`requiredNpcInstanceIds`** set against **`PrototypeNpcRegistry`**, and enforces the **prototype E5M3** one-encounter roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||||
|
||||
| Config | Meaning |
|
||||
|--------|---------|
|
||||
| **`Content:EncountersDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/encounters`**. |
|
||||
| **`Content:EncounterDefSchemaPath`** | Optional override for **`encounter-def.schema.json`**. When unset, **`{parent of encounters directory}/schemas/encounter-def.schema.json`**. |
|
||||
|
||||
**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.
|
||||
|
||||
## NPC behavior definitions (NEO-90)
|
||||
|
||||
**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue