diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs new file mode 100644 index 0000000..f67cf48 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs @@ -0,0 +1,256 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public class QuestDefinitionRegistryTests +{ + private const string GatherIntroId = "prototype_quest_gather_intro"; + private const string OperatorChainId = "prototype_quest_operator_chain"; + + public static TheoryData FrozenQuestIds => + new(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal)); + + private static QuestDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary byId) + { + var catalog = new QuestDefinitionCatalog("/tmp/quests", byId, catalogJsonFileCount: 1); + return new QuestDefinitionRegistry(catalog); + } + + private static QuestDefRow CreateGatherIntroRow() => + new( + GatherIntroId, + "Intro: Salvage Run", + [], + [ + new QuestStepDefRow( + "gather_intro_step_salvage", + "Gather scrap metal", + [ + new QuestObjectiveDefRow( + "gather_intro_obj_scrap", + "gather_item", + "scrap_metal_bulk", + 3, + null, + null), + ]), + ]); + + [Theory] + [MemberData(nameof(FrozenQuestIds))] + public void TryGetDefinition_ShouldReturnTrue_WhenFrozenQuestIdExists(string questId) + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [questId] = new QuestDefRow(questId, "Test Quest", [], []), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(questId, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal(questId, def!.Id); + Assert.Equal("Test Quest", def.DisplayName); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenGatherIntroExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(GatherIntroId, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal("Intro: Salvage Run", def!.DisplayName); + Assert.Single(def.Steps); + Assert.Equal("gather_intro_step_salvage", def.Steps[0].Id); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndFourSteps_WhenOperatorChainExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [OperatorChainId] = new QuestDefRow( + OperatorChainId, + "Operator Chain", + ["prototype_quest_gather_intro"], + [ + new QuestStepDefRow("chain_step_gather", "Bulk salvage", []), + new QuestStepDefRow("chain_step_refine", "Refine stock", []), + new QuestStepDefRow("chain_step_stim", "Craft field stim", []), + new QuestStepDefRow("chain_step_token", "Hand off contract token", []), + ]), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(OperatorChainId, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal(4, def!.Steps.Count); + Assert.Equal("chain_step_token", def.Steps[3].Id); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenQuestIdIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(null, out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("not_a_real_quest", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" Prototype_Quest_Gather_Intro ", out var normalized); + // Assert + Assert.True(found); + Assert.Equal(GatherIntroId, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(null, out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" ", out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenIdNotInCatalog() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown("prototype_unknown", out var normalized); + // Assert + Assert.False(found); + Assert.Equal("prototype_unknown", normalized); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleQuests() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["z_quest"] = new QuestDefRow("z_quest", "Z", [], []), + [GatherIntroId] = CreateGatherIntroRow(), + ["a_quest"] = new QuestDefRow("a_quest", "A", [], []), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(3, list.Count); + Assert.Equal("a_quest", list[0].Id); + Assert.Equal(GatherIntroId, list[1].Id); + Assert.Equal("z_quest", list[2].Id); + } + + [Fact] + public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + // Act + var registry = factory.Services.GetRequiredService(); + var gatherFound = registry.TryGetDefinition(GatherIntroId, out var gather); + var unknown = registry.TryGetDefinition("not_a_real_quest", out var missing); + var normalized = registry.TryNormalizeKnown(" Prototype_Quest_Gather_Intro ", out var questId); + var unknownNormalized = registry.TryNormalizeKnown("prototype_unknown", out var unknownQuestId); + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.True(gatherFound); + Assert.NotNull(gather); + Assert.Equal("Intro: Salvage Run", gather!.DisplayName); + Assert.False(unknown); + Assert.Null(missing); + Assert.True(normalized); + Assert.Equal(GatherIntroId, questId); + Assert.False(unknownNormalized); + Assert.Equal("prototype_unknown", unknownQuestId); + Assert.Equal(4, list.Count); + Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal), list.Select(q => q.Id)); + foreach (var expectedId in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds) + { + Assert.True(registry.TryGetDefinition(expectedId, out var def)); + Assert.NotNull(def); + Assert.False(string.IsNullOrWhiteSpace(def!.DisplayName)); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/IQuestDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Quests/IQuestDefinitionRegistry.cs new file mode 100644 index 0000000..86a2255 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/IQuestDefinitionRegistry.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Quests; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +/// +/// E7.M1 (quest accept / progress / objective advance): callers validating quest ids and resolving quest metadata should depend on this interface +/// rather than so quest defs stay centralized. +/// NEO-115: HTTP/read-model projections should depend on this interface rather than reaching into the catalog. +/// +public interface IQuestDefinitionRegistry +{ + /// Attempts to resolve a quest by stable id (see quest-def.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? questId, [NotNullWhen(true)] out QuestDefRow? definition); + + /// Trims and lowercases ; returns true when the normalized id exists in the loaded catalog. null, empty, and whitespace return false without throwing. + bool TryNormalizeKnown(string? rawQuestId, [NotNullWhen(true)] out string normalized); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs index 10e3be7..622e726 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs @@ -8,10 +8,10 @@ using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Quests; -/// DI registration for the fail-fast quest catalog (NEO-113). +/// DI registration for the fail-fast quest catalog and registry (NEO-113, NEO-114). public static class QuestCatalogServiceCollectionExtensions { - /// Binds and registers as a singleton. + /// Binds and registers and as singletons. public static IServiceCollection AddQuestDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -58,6 +58,9 @@ public static class QuestCatalogServiceCollectionExtensions logger); }); + services.AddSingleton(sp => + new QuestDefinitionRegistry(sp.GetRequiredService())); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionRegistry.cs new file mode 100644 index 0000000..b6c2d3b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionRegistry.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Quests; + +/// Adapter over (NEO-114). +public sealed class QuestDefinitionRegistry(QuestDefinitionCatalog catalog) : IQuestDefinitionRegistry +{ + private readonly IReadOnlyList _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog); + + /// + public bool TryGetDefinition(string? questId, [NotNullWhen(true)] out QuestDefRow? definition) + { + if (questId is null) + { + definition = null; + return false; + } + + return catalog.TryGetQuest(questId, out definition); + } + + /// + public bool TryNormalizeKnown(string? rawQuestId, [NotNullWhen(true)] out string normalized) + { + if (rawQuestId is null) + { + normalized = string.Empty; + return false; + } + + normalized = rawQuestId.Trim().ToLowerInvariant(); + if (normalized.Length == 0) + { + normalized = string.Empty; + return false; + } + + return catalog.TryGetQuest(normalized, out _); + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() => _definitionsInIdOrder; + + private static IReadOnlyList BuildDefinitionsInIdOrder(QuestDefinitionCatalog catalog) + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.ById[id]); + } + + return list; + } +}