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