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
parent
47ddde671f
commit
3924695619
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue