NEO-125: fail-fast quest completionRewardBundle validation at startup

Register bundle JSON Schemas, parse bundle DTOs on QuestDefRow, and run
E7M2 prototype gates (presence, freeze table, cross-ref, mission_reward)
with skill catalog input from DI.
pull/164/head
VinPropane 2026-06-07 15:25:58 -04:00
parent 47ddde671f
commit 3924695619
10 changed files with 477 additions and 8 deletions

View File

@ -26,4 +26,13 @@ internal static class QuestCatalogTestPaths
DiscoverRepoQuestsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
internal static string DiscoverRepoRewardGrantRowSchemaPath() =>
QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(DiscoverRepoQuestsDirectory());
internal static string DiscoverRepoQuestSkillXpGrantSchemaPath() =>
QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(DiscoverRepoQuestsDirectory());
internal static string DiscoverRepoQuestRewardBundleSchemaPath() =>
QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoQuestsDirectory());
}

View File

@ -9,7 +9,9 @@ using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
@ -22,6 +24,17 @@ public class QuestDefinitionCatalogLoaderTests
private static readonly FrozenSet<string> KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds;
private static readonly IReadOnlyDictionary<string, SkillDefRow> RepoSkillDefs = LoadRepoSkillDefs();
private static IReadOnlyDictionary<string, SkillDefRow> LoadRepoSkillDefs()
{
var catalog = SkillDefinitionCatalogLoader.Load(
SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(),
SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(),
NullLogger.Instance);
return catalog.ById;
}
private const string ValidPrototypeCatalogJson =
"""
{
@ -31,6 +44,9 @@ public class QuestDefinitionCatalogLoaderTests
"id": "prototype_quest_gather_intro",
"displayName": "Intro: Salvage Run",
"prerequisiteQuestIds": [],
"completionRewardBundle": {
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
},
"steps": [
{
"id": "gather_intro_step_salvage",
@ -50,6 +66,9 @@ public class QuestDefinitionCatalogLoaderTests
"id": "prototype_quest_refine_intro",
"displayName": "Intro: Refine Stock",
"prerequisiteQuestIds": ["prototype_quest_gather_intro"],
"completionRewardBundle": {
"skillXpGrants": [{ "skillId": "refine", "amount": 25 }]
},
"steps": [
{
"id": "refine_intro_step_craft",
@ -69,6 +88,9 @@ public class QuestDefinitionCatalogLoaderTests
"id": "prototype_quest_combat_intro",
"displayName": "Intro: Clear the Pocket",
"prerequisiteQuestIds": [],
"completionRewardBundle": {
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
},
"steps": [
{
"id": "combat_intro_step_encounter",
@ -91,6 +113,10 @@ public class QuestDefinitionCatalogLoaderTests
"prototype_quest_refine_intro",
"prototype_quest_combat_intro"
],
"completionRewardBundle": {
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
},
"steps": [
{
"id": "chain_step_gather",
@ -159,6 +185,9 @@ public class QuestDefinitionCatalogLoaderTests
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(), defSchemaPath, overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(), stepSchemaPath, overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(), objectiveSchemaPath, overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
}
@ -182,15 +211,20 @@ public class QuestDefinitionCatalogLoaderTests
string questsDir,
string defSchemaPath,
string stepSchemaPath,
string objectiveSchemaPath) =>
string objectiveSchemaPath,
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null) =>
QuestDefinitionCatalogLoader.Load(
questsDir,
defSchemaPath,
stepSchemaPath,
objectiveSchemaPath,
QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(questsDir),
QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(questsDir),
QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(questsDir),
KnownItemIds,
KnownRecipeIds,
KnownEncounterIds,
skillDefsById ?? RepoSkillDefs,
NullLogger.Instance);
[Fact]
@ -229,6 +263,10 @@ public class QuestDefinitionCatalogLoaderTests
Assert.Equal(4, chain!.Steps.Count);
Assert.Equal("inventory_has_item", chain.Steps[^1].Objectives[0].Kind);
Assert.Equal("contract_handoff_token", chain.Steps[^1].Objectives[0].ItemId);
Assert.NotNull(gather!.CompletionRewardBundle);
Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants);
Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId);
Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount);
}
[Fact]
@ -567,6 +605,110 @@ public class QuestDefinitionCatalogLoaderTests
Assert.Contains("terminal quantity must be 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingCompletionRewardBundleOnFrozenQuest()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var gather = GetQuestRow(root, "prototype_quest_gather_intro");
gather.Remove("completionRewardBundle");
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("must include completionRewardBundle object", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenCompletionRewardBundleAmountDoesNotMatchFreezeTable()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var gather = GetQuestRow(root, "prototype_quest_gather_intro");
var bundle = gather["completionRewardBundle"] as JsonObject
?? throw new InvalidOperationException("expected bundle");
var skillXpGrants = bundle["skillXpGrants"] as JsonArray
?? throw new InvalidOperationException("expected skillXpGrants");
var grant = skillXpGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant");
grant["amount"] = 99;
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownItemIdInCompletionRewardBundle()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var chain = GetQuestRow(root, "prototype_quest_operator_chain");
var bundle = chain["completionRewardBundle"] as JsonObject
?? throw new InvalidOperationException("expected bundle");
var itemGrants = bundle["itemGrants"] as JsonArray
?? throw new InvalidOperationException("expected itemGrants");
var grant = itemGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant");
grant["itemId"] = "unknown_item_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert — freeze gate runs before cross-ref (same order as validate_content.py)
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownSkillIdInCompletionRewardBundle()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var gather = GetQuestRow(root, "prototype_quest_gather_intro");
var bundle = gather["completionRewardBundle"] as JsonObject
?? throw new InvalidOperationException("expected bundle");
var skillXpGrants = bundle["skillXpGrants"] as JsonArray
?? throw new InvalidOperationException("expected skillXpGrants");
var grant = skillXpGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant");
grant["skillId"] = "unknown_skill_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert — freeze gate runs before cross-ref (same order as validate_content.py)
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSkillMissingMissionRewardAllowlist()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
WriteCatalog(questsDir, root.ToJsonString());
var salvageNoMissionReward = new SkillDefRow("salvage", "gather", "Salvage", ["activity"]);
var skillDefs = new Dictionary<string, SkillDefRow>(RepoSkillDefs, StringComparer.Ordinal)
{
["salvage"] = salvageNoMissionReward,
};
// Act
var ex = Record.Exception(() =>
LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath, skillDefs));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingSchemaFile()
{

View File

@ -0,0 +1,183 @@
using System.Collections.Frozen;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Prototype E7M2 completion bundle gates (NEO-125), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M2_*</c> and <c>_prototype_e7m2_*</c> gate functions.
/// </summary>
public static class PrototypeE7M2QuestCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND</c>.</summary>
public const string MissionRewardSourceKind = "mission_reward";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M2_COMPLETION_BUNDLES</c>.</summary>
public static readonly FrozenDictionary<string, QuestRewardBundleRow> ExpectedCompletionBundles =
new Dictionary<string, QuestRewardBundleRow>(StringComparer.Ordinal)
{
[PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = new(
[],
[new QuestSkillXpGrantRow("salvage", 25)]),
[PrototypeE7M1QuestCatalogRules.RefineIntroQuestId] = new(
[],
[new QuestSkillXpGrantRow("refine", 25)]),
[PrototypeE7M1QuestCatalogRules.CombatIntroQuestId] = new(
[],
[new QuestSkillXpGrantRow("salvage", 25)]),
[PrototypeE7M1QuestCatalogRules.ChainQuestId] = new(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)]),
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise <see langword="null"/>.</summary>
public static string? TryGetCompletionBundlePresenceError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
{
foreach (var qid in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal))
{
if (!rowsById.TryGetValue(qid, out var row))
return $"error: missing quest '{qid}'";
if (row.CompletionRewardBundle is null)
return $"error: quest '{qid}' must include completionRewardBundle object";
}
return null;
}
/// <summary>Returns a human-readable error if bundle contents diverge from the E7M2 freeze table, otherwise <see langword="null"/>.</summary>
public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
{
foreach (var (qid, expected) in ExpectedCompletionBundles)
{
if (!rowsById.TryGetValue(qid, out var row))
return $"error: missing quest '{qid}'";
if (row.CompletionRewardBundle is null)
return $"error: quest '{qid}' must include completionRewardBundle object";
var actual = NormalizeBundle(row.CompletionRewardBundle);
var expectedNormalized = NormalizeBundle(expected);
if (!BundlesEqual(actual, expectedNormalized))
{
return
$"error: quest '{qid}' completionRewardBundle must match E7M2 freeze table " +
$"(expected {FormatBundle(expectedNormalized)}, got {FormatBundle(actual)})";
}
}
return null;
}
/// <summary>Returns a human-readable error when bundle cross-refs fail, otherwise <see langword="null"/>.</summary>
public static string? TryGetCompletionBundleCrossRefError(
IReadOnlyDictionary<string, QuestDefRow> rowsById,
IReadOnlyDictionary<string, SkillDefRow> skillDefsById)
{
foreach (var qid in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal))
{
if (!rowsById.TryGetValue(qid, out var row) || row.CompletionRewardBundle is null)
continue;
var bundle = row.CompletionRewardBundle;
for (var gi = 0; gi < bundle.ItemGrants.Count; gi++)
{
var grant = bundle.ItemGrants[gi];
if (!PrototypeSlice1ItemCatalogRules.ExpectedItemIds.Contains(grant.ItemId))
{
return
$"error: quest '{qid}' completionRewardBundle.itemGrants[{gi}]: itemId " +
$"'{grant.ItemId}' is not in the frozen prototype item catalog";
}
}
for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++)
{
var grant = bundle.SkillXpGrants[gi];
if (!PrototypeSlice1SkillCatalogRules.ExpectedSkillIds.Contains(grant.SkillId))
{
return
$"error: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
$"'{grant.SkillId}' is not in the frozen prototype skill catalog";
}
if (!skillDefsById.TryGetValue(grant.SkillId, out var skill))
{
return
$"error: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
$"'{grant.SkillId}' is not in the frozen prototype skill catalog";
}
var allowedKinds = skill.AllowedXpSourceKinds;
if (!allowedKinds.Contains(MissionRewardSourceKind, StringComparer.Ordinal))
{
var kindsDisplay = $"[{string.Join(", ", allowedKinds.Select(k => $"'{k}'"))}]";
return
$"error: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
$"'{grant.SkillId}' must allow sourceKind '{MissionRewardSourceKind}' in allowedXpSourceKinds " +
$"(got {kindsDisplay})";
}
}
}
return null;
}
private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) =>
new(
bundle.ItemGrants
.OrderBy(g => g.ItemId, StringComparer.Ordinal)
.ThenBy(g => g.Quantity)
.ToArray(),
bundle.SkillXpGrants
.OrderBy(g => g.SkillId, StringComparer.Ordinal)
.ThenBy(g => g.Amount)
.ToArray());
private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right)
{
if (left.ItemGrants.Length != right.ItemGrants.Length ||
left.SkillXpGrants.Length != right.SkillXpGrants.Length)
{
return false;
}
for (var i = 0; i < left.ItemGrants.Length; i++)
{
if (left.ItemGrants[i].ItemId != right.ItemGrants[i].ItemId ||
left.ItemGrants[i].Quantity != right.ItemGrants[i].Quantity)
{
return false;
}
}
for (var i = 0; i < left.SkillXpGrants.Length; i++)
{
if (left.SkillXpGrants[i].SkillId != right.SkillXpGrants[i].SkillId ||
left.SkillXpGrants[i].Amount != right.SkillXpGrants[i].Amount)
{
return false;
}
}
return true;
}
private static string FormatBundle(NormalizedBundle bundle)
{
var itemGrants = string.Join(
", ",
bundle.ItemGrants.Select(g => $"{{ itemId: '{g.ItemId}', quantity: {g.Quantity} }}"));
var skillXpGrants = string.Join(
", ",
bundle.SkillXpGrants.Select(g => $"{{ skillId: '{g.SkillId}', amount: {g.Amount} }}"));
return
$"{{ itemGrants: [{itemGrants}], skillXpGrants: [{skillXpGrants}] }}";
}
private sealed record NormalizedBundle(
RewardGrantRow[] ItemGrants,
QuestSkillXpGrantRow[] SkillXpGrants);
}

View File

@ -93,4 +93,16 @@ public static class QuestCatalogPathResolution
return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-objective-def.schema.json"));
}
/// <summary>Resolves JSON Schema path for a reward grant row (shared by reward tables and quest bundles).</summary>
public static string ResolveRewardGrantRowSchemaPath(string questsDirectory) =>
Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "reward-grant-row.schema.json"));
/// <summary>Resolves JSON Schema path for a quest skill XP grant row.</summary>
public static string ResolveQuestSkillXpGrantSchemaPath(string questsDirectory) =>
Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json"));
/// <summary>Resolves JSON Schema path for a quest completion reward bundle.</summary>
public static string ResolveQuestRewardBundleSchemaPath(string questsDirectory) =>
Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-reward-bundle.schema.json"));
}

View File

@ -24,6 +24,7 @@ public static class QuestCatalogServiceCollectionExtensions
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog");
@ -42,6 +43,9 @@ public static class QuestCatalogServiceCollectionExtensions
questsDir,
opts.QuestObjectiveDefSchemaPath,
hostEnv.ContentRootPath);
var rewardGrantRowSchemaPath = QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(questsDir);
var questSkillXpGrantSchemaPath = QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(questsDir);
var questRewardBundleSchemaPath = QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(questsDir);
var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
@ -52,9 +56,13 @@ public static class QuestCatalogServiceCollectionExtensions
questDefSchemaPath,
questStepDefSchemaPath,
questObjectiveDefSchemaPath,
rewardGrantRowSchemaPath,
questSkillXpGrantSchemaPath,
questRewardBundleSchemaPath,
knownItemIds,
knownRecipeIds,
knownEncounterIds,
skillCatalog.ById,
logger);
});

View File

@ -5,7 +5,8 @@ public sealed class QuestDefRow(
string id,
string displayName,
IReadOnlyList<string> prerequisiteQuestIds,
IReadOnlyList<QuestStepDefRow> steps)
IReadOnlyList<QuestStepDefRow> steps,
QuestRewardBundleRow? completionRewardBundle = null)
{
public string Id { get; } = id;
@ -14,4 +15,6 @@ public sealed class QuestDefRow(
public IReadOnlyList<string> PrerequisiteQuestIds { get; } = prerequisiteQuestIds;
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle;
}

View File

@ -3,10 +3,12 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113).</summary>
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113, NEO-125).</summary>
public static class QuestDefinitionCatalogLoader
{
private static JsonSchema? _questObjectiveDefSchema;
@ -18,15 +20,22 @@ public static class QuestDefinitionCatalogLoader
string questDefSchemaPath,
string questStepDefSchemaPath,
string questObjectiveDefSchemaPath,
string rewardGrantRowSchemaPath,
string questSkillXpGrantSchemaPath,
string questRewardBundleSchemaPath,
IReadOnlySet<string> knownItemIds,
IReadOnlySet<string> knownRecipeIds,
IReadOnlySet<string> knownEncounterIds,
IReadOnlyDictionary<string, SkillDefRow> skillDefsById,
ILogger logger)
{
questsDirectory = Path.GetFullPath(questsDirectory);
questDefSchemaPath = Path.GetFullPath(questDefSchemaPath);
questStepDefSchemaPath = Path.GetFullPath(questStepDefSchemaPath);
questObjectiveDefSchemaPath = Path.GetFullPath(questObjectiveDefSchemaPath);
rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath);
questSkillXpGrantSchemaPath = Path.GetFullPath(questSkillXpGrantSchemaPath);
questRewardBundleSchemaPath = Path.GetFullPath(questRewardBundleSchemaPath);
var errors = new List<string>();
@ -39,6 +48,15 @@ public static class QuestDefinitionCatalogLoader
if (!File.Exists(questObjectiveDefSchemaPath))
errors.Add($"error: missing schema file {questObjectiveDefSchemaPath}");
if (!File.Exists(rewardGrantRowSchemaPath))
errors.Add($"error: missing schema file {rewardGrantRowSchemaPath}");
if (!File.Exists(questSkillXpGrantSchemaPath))
errors.Add($"error: missing schema file {questSkillXpGrantSchemaPath}");
if (!File.Exists(questRewardBundleSchemaPath))
errors.Add($"error: missing schema file {questRewardBundleSchemaPath}");
if (!Directory.Exists(questsDirectory))
errors.Add($"error: missing directory {questsDirectory}");
@ -54,7 +72,13 @@ public static class QuestDefinitionCatalogLoader
if (errors.Count > 0)
ThrowIfAny(errors);
EnsureQuestSchemasLoaded(questObjectiveDefSchemaPath, questStepDefSchemaPath, questDefSchemaPath);
EnsureQuestSchemasLoaded(
questObjectiveDefSchemaPath,
questStepDefSchemaPath,
rewardGrantRowSchemaPath,
questSkillXpGrantSchemaPath,
questRewardBundleSchemaPath,
questDefSchemaPath);
var defSchema = _questDefSchema!;
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
@ -169,6 +193,27 @@ public static class QuestDefinitionCatalogLoader
ThrowIfAny(errors);
}
var bundlePresence = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundlePresenceError(rows);
if (bundlePresence is not null)
{
errors.Add(bundlePresence);
ThrowIfAny(errors);
}
var bundleFreeze = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleFreezeError(rows);
if (bundleFreeze is not null)
{
errors.Add(bundleFreeze);
ThrowIfAny(errors);
}
var bundleCrossRef = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(rows, skillDefsById);
if (bundleCrossRef is not null)
{
errors.Add(bundleCrossRef);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
@ -260,7 +305,56 @@ public static class QuestDefinitionCatalogLoader
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray);
return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps);
var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject);
return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle);
}
private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj)
{
if (bundleObj is null)
return null;
var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray);
var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray);
return new QuestRewardBundleRow(itemGrants, skillXpGrants);
}
private static IReadOnlyList<RewardGrantRow> ParseItemGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
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 IReadOnlyList<QuestSkillXpGrantRow> ParseSkillXpGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List<QuestSkillXpGrantRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var skillId = (grantObj["skillId"] as JsonValue)!.GetValue<string>();
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
rows.Add(new QuestSkillXpGrantRow(skillId, amount));
}
return rows;
}
private static QuestStepDefRow ParseStep(JsonObject stepObj)
@ -345,15 +439,21 @@ public static class QuestDefinitionCatalogLoader
}
}
/// <summary>Registers objective → step → def in <see cref="SchemaRegistry.Global"/> once per process (NEO-113).</summary>
/// <summary>Registers bundle + objective → step → def in <see cref="SchemaRegistry.Global"/> once per process (NEO-113, NEO-125).</summary>
private static void EnsureQuestSchemasLoaded(
string questObjectiveDefSchemaPath,
string questStepDefSchemaPath,
string rewardGrantRowSchemaPath,
string questSkillXpGrantSchemaPath,
string questRewardBundleSchemaPath,
string questDefSchemaPath)
{
if (_questDefSchema is not null)
return;
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);
_questObjectiveDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questObjectiveDefSchemaPath);
_questStepDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questStepDefSchemaPath);
_questDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questDefSchemaPath);

View File

@ -0,0 +1,8 @@
using NeonSprawl.Server.Game.Encounters;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Composite completion rewards on <see cref="QuestDefRow.CompletionRewardBundle"/> (NEO-125).</summary>
public sealed record QuestRewardBundleRow(
IReadOnlyList<RewardGrantRow> ItemGrants,
IReadOnlyList<QuestSkillXpGrantRow> SkillXpGrants);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Single skill XP row on a quest <see cref="QuestRewardBundleRow"/> (NEO-125).</summary>
public sealed record QuestSkillXpGrantRow(string SkillId, int Amount);

View File

@ -135,7 +135,7 @@ On success, **Information** logs include the resolved encounters directory path,
## Quest catalog (`content/quests`, NEO-113)
On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, and encounter catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`** and **`quest-objective-def.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, and enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 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.
On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, encounter, and **skill** catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`**, **`quest-objective-def.schema.json`**, **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, and **`reward-grant-row.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate, and enforces **prototype E7M2** **`completionRewardBundle`** presence, freeze-table contents, item/skill cross-refs, and **`mission_reward`** allowlist on skill XP targets (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 |
|--------|---------|
@ -144,7 +144,7 @@ On startup the host loads every **`*_quests.json`** under the quests directory *
| **`Content:QuestStepDefSchemaPath`** | Optional override for **`quest-step-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-step-def.schema.json`**. |
| **`Content:QuestObjectiveDefSchemaPath`** | Optional override for **`quest-objective-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-objective-def.schema.json`**. |
**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**), and the three quest schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs.
**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**), and quest/bundle schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs.
On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes.