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(),
|
DiscoverRepoQuestsDirectory(),
|
||||||
configuredSchemaPath: null,
|
configuredSchemaPath: null,
|
||||||
contentRootPath: string.Empty);
|
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.Encounters;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Quests;
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using NeonSprawl.Server.Tests;
|
using NeonSprawl.Server.Tests;
|
||||||
|
using NeonSprawl.Server.Tests.Game.Skills;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||||
|
|
@ -22,6 +24,17 @@ public class QuestDefinitionCatalogLoaderTests
|
||||||
|
|
||||||
private static readonly FrozenSet<string> KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds;
|
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 =
|
private const string ValidPrototypeCatalogJson =
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
|
|
@ -31,6 +44,9 @@ public class QuestDefinitionCatalogLoaderTests
|
||||||
"id": "prototype_quest_gather_intro",
|
"id": "prototype_quest_gather_intro",
|
||||||
"displayName": "Intro: Salvage Run",
|
"displayName": "Intro: Salvage Run",
|
||||||
"prerequisiteQuestIds": [],
|
"prerequisiteQuestIds": [],
|
||||||
|
"completionRewardBundle": {
|
||||||
|
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
|
||||||
|
},
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"id": "gather_intro_step_salvage",
|
"id": "gather_intro_step_salvage",
|
||||||
|
|
@ -50,6 +66,9 @@ public class QuestDefinitionCatalogLoaderTests
|
||||||
"id": "prototype_quest_refine_intro",
|
"id": "prototype_quest_refine_intro",
|
||||||
"displayName": "Intro: Refine Stock",
|
"displayName": "Intro: Refine Stock",
|
||||||
"prerequisiteQuestIds": ["prototype_quest_gather_intro"],
|
"prerequisiteQuestIds": ["prototype_quest_gather_intro"],
|
||||||
|
"completionRewardBundle": {
|
||||||
|
"skillXpGrants": [{ "skillId": "refine", "amount": 25 }]
|
||||||
|
},
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"id": "refine_intro_step_craft",
|
"id": "refine_intro_step_craft",
|
||||||
|
|
@ -69,6 +88,9 @@ public class QuestDefinitionCatalogLoaderTests
|
||||||
"id": "prototype_quest_combat_intro",
|
"id": "prototype_quest_combat_intro",
|
||||||
"displayName": "Intro: Clear the Pocket",
|
"displayName": "Intro: Clear the Pocket",
|
||||||
"prerequisiteQuestIds": [],
|
"prerequisiteQuestIds": [],
|
||||||
|
"completionRewardBundle": {
|
||||||
|
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
|
||||||
|
},
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"id": "combat_intro_step_encounter",
|
"id": "combat_intro_step_encounter",
|
||||||
|
|
@ -91,6 +113,10 @@ public class QuestDefinitionCatalogLoaderTests
|
||||||
"prototype_quest_refine_intro",
|
"prototype_quest_refine_intro",
|
||||||
"prototype_quest_combat_intro"
|
"prototype_quest_combat_intro"
|
||||||
],
|
],
|
||||||
|
"completionRewardBundle": {
|
||||||
|
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
|
||||||
|
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
|
||||||
|
},
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"id": "chain_step_gather",
|
"id": "chain_step_gather",
|
||||||
|
|
@ -159,6 +185,9 @@ public class QuestDefinitionCatalogLoaderTests
|
||||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(), defSchemaPath, overwrite: true);
|
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(), defSchemaPath, overwrite: true);
|
||||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(), stepSchemaPath, overwrite: true);
|
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(), stepSchemaPath, overwrite: true);
|
||||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(), objectiveSchemaPath, 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);
|
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,15 +211,20 @@ public class QuestDefinitionCatalogLoaderTests
|
||||||
string questsDir,
|
string questsDir,
|
||||||
string defSchemaPath,
|
string defSchemaPath,
|
||||||
string stepSchemaPath,
|
string stepSchemaPath,
|
||||||
string objectiveSchemaPath) =>
|
string objectiveSchemaPath,
|
||||||
|
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null) =>
|
||||||
QuestDefinitionCatalogLoader.Load(
|
QuestDefinitionCatalogLoader.Load(
|
||||||
questsDir,
|
questsDir,
|
||||||
defSchemaPath,
|
defSchemaPath,
|
||||||
stepSchemaPath,
|
stepSchemaPath,
|
||||||
objectiveSchemaPath,
|
objectiveSchemaPath,
|
||||||
|
QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(questsDir),
|
||||||
|
QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(questsDir),
|
||||||
|
QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(questsDir),
|
||||||
KnownItemIds,
|
KnownItemIds,
|
||||||
KnownRecipeIds,
|
KnownRecipeIds,
|
||||||
KnownEncounterIds,
|
KnownEncounterIds,
|
||||||
|
skillDefsById ?? RepoSkillDefs,
|
||||||
NullLogger.Instance);
|
NullLogger.Instance);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -229,6 +263,10 @@ public class QuestDefinitionCatalogLoaderTests
|
||||||
Assert.Equal(4, chain!.Steps.Count);
|
Assert.Equal(4, chain!.Steps.Count);
|
||||||
Assert.Equal("inventory_has_item", chain.Steps[^1].Objectives[0].Kind);
|
Assert.Equal("inventory_has_item", chain.Steps[^1].Objectives[0].Kind);
|
||||||
Assert.Equal("contract_handoff_token", chain.Steps[^1].Objectives[0].ItemId);
|
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]
|
[Fact]
|
||||||
|
|
@ -567,6 +605,110 @@ public class QuestDefinitionCatalogLoaderTests
|
||||||
Assert.Contains("terminal quantity must be 1", ioe.Message, StringComparison.Ordinal);
|
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]
|
[Fact]
|
||||||
public void Load_ShouldThrow_WhenMissingSchemaFile()
|
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"));
|
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 itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
|
||||||
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
|
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
|
||||||
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
|
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
|
||||||
|
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
|
||||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog");
|
.CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog");
|
||||||
|
|
||||||
|
|
@ -42,6 +43,9 @@ public static class QuestCatalogServiceCollectionExtensions
|
||||||
questsDir,
|
questsDir,
|
||||||
opts.QuestObjectiveDefSchemaPath,
|
opts.QuestObjectiveDefSchemaPath,
|
||||||
hostEnv.ContentRootPath);
|
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 knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||||
var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
@ -52,9 +56,13 @@ public static class QuestCatalogServiceCollectionExtensions
|
||||||
questDefSchemaPath,
|
questDefSchemaPath,
|
||||||
questStepDefSchemaPath,
|
questStepDefSchemaPath,
|
||||||
questObjectiveDefSchemaPath,
|
questObjectiveDefSchemaPath,
|
||||||
|
rewardGrantRowSchemaPath,
|
||||||
|
questSkillXpGrantSchemaPath,
|
||||||
|
questRewardBundleSchemaPath,
|
||||||
knownItemIds,
|
knownItemIds,
|
||||||
knownRecipeIds,
|
knownRecipeIds,
|
||||||
knownEncounterIds,
|
knownEncounterIds,
|
||||||
|
skillCatalog.ById,
|
||||||
logger);
|
logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ public sealed class QuestDefRow(
|
||||||
string id,
|
string id,
|
||||||
string displayName,
|
string displayName,
|
||||||
IReadOnlyList<string> prerequisiteQuestIds,
|
IReadOnlyList<string> prerequisiteQuestIds,
|
||||||
IReadOnlyList<QuestStepDefRow> steps)
|
IReadOnlyList<QuestStepDefRow> steps,
|
||||||
|
QuestRewardBundleRow? completionRewardBundle = null)
|
||||||
{
|
{
|
||||||
public string Id { get; } = id;
|
public string Id { get; } = id;
|
||||||
|
|
||||||
|
|
@ -14,4 +15,6 @@ public sealed class QuestDefRow(
|
||||||
public IReadOnlyList<string> PrerequisiteQuestIds { get; } = prerequisiteQuestIds;
|
public IReadOnlyList<string> PrerequisiteQuestIds { get; } = prerequisiteQuestIds;
|
||||||
|
|
||||||
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
|
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 System.Text.Json.Nodes;
|
||||||
using Json.Schema;
|
using Json.Schema;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Quests;
|
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
|
public static class QuestDefinitionCatalogLoader
|
||||||
{
|
{
|
||||||
private static JsonSchema? _questObjectiveDefSchema;
|
private static JsonSchema? _questObjectiveDefSchema;
|
||||||
|
|
@ -18,15 +20,22 @@ public static class QuestDefinitionCatalogLoader
|
||||||
string questDefSchemaPath,
|
string questDefSchemaPath,
|
||||||
string questStepDefSchemaPath,
|
string questStepDefSchemaPath,
|
||||||
string questObjectiveDefSchemaPath,
|
string questObjectiveDefSchemaPath,
|
||||||
|
string rewardGrantRowSchemaPath,
|
||||||
|
string questSkillXpGrantSchemaPath,
|
||||||
|
string questRewardBundleSchemaPath,
|
||||||
IReadOnlySet<string> knownItemIds,
|
IReadOnlySet<string> knownItemIds,
|
||||||
IReadOnlySet<string> knownRecipeIds,
|
IReadOnlySet<string> knownRecipeIds,
|
||||||
IReadOnlySet<string> knownEncounterIds,
|
IReadOnlySet<string> knownEncounterIds,
|
||||||
|
IReadOnlyDictionary<string, SkillDefRow> skillDefsById,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
{
|
{
|
||||||
questsDirectory = Path.GetFullPath(questsDirectory);
|
questsDirectory = Path.GetFullPath(questsDirectory);
|
||||||
questDefSchemaPath = Path.GetFullPath(questDefSchemaPath);
|
questDefSchemaPath = Path.GetFullPath(questDefSchemaPath);
|
||||||
questStepDefSchemaPath = Path.GetFullPath(questStepDefSchemaPath);
|
questStepDefSchemaPath = Path.GetFullPath(questStepDefSchemaPath);
|
||||||
questObjectiveDefSchemaPath = Path.GetFullPath(questObjectiveDefSchemaPath);
|
questObjectiveDefSchemaPath = Path.GetFullPath(questObjectiveDefSchemaPath);
|
||||||
|
rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath);
|
||||||
|
questSkillXpGrantSchemaPath = Path.GetFullPath(questSkillXpGrantSchemaPath);
|
||||||
|
questRewardBundleSchemaPath = Path.GetFullPath(questRewardBundleSchemaPath);
|
||||||
|
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
|
@ -39,6 +48,15 @@ public static class QuestDefinitionCatalogLoader
|
||||||
if (!File.Exists(questObjectiveDefSchemaPath))
|
if (!File.Exists(questObjectiveDefSchemaPath))
|
||||||
errors.Add($"error: missing schema file {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))
|
if (!Directory.Exists(questsDirectory))
|
||||||
errors.Add($"error: missing directory {questsDirectory}");
|
errors.Add($"error: missing directory {questsDirectory}");
|
||||||
|
|
||||||
|
|
@ -54,7 +72,13 @@ public static class QuestDefinitionCatalogLoader
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
ThrowIfAny(errors);
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
EnsureQuestSchemasLoaded(questObjectiveDefSchemaPath, questStepDefSchemaPath, questDefSchemaPath);
|
EnsureQuestSchemasLoaded(
|
||||||
|
questObjectiveDefSchemaPath,
|
||||||
|
questStepDefSchemaPath,
|
||||||
|
rewardGrantRowSchemaPath,
|
||||||
|
questSkillXpGrantSchemaPath,
|
||||||
|
questRewardBundleSchemaPath,
|
||||||
|
questDefSchemaPath);
|
||||||
var defSchema = _questDefSchema!;
|
var defSchema = _questDefSchema!;
|
||||||
|
|
||||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||||
|
|
@ -169,6 +193,27 @@ public static class QuestDefinitionCatalogLoader
|
||||||
ThrowIfAny(errors);
|
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))
|
if (logger.IsEnabled(LogLevel.Information))
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
|
|
@ -260,7 +305,56 @@ public static class QuestDefinitionCatalogLoader
|
||||||
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||||
var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray);
|
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)
|
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(
|
private static void EnsureQuestSchemasLoaded(
|
||||||
string questObjectiveDefSchemaPath,
|
string questObjectiveDefSchemaPath,
|
||||||
string questStepDefSchemaPath,
|
string questStepDefSchemaPath,
|
||||||
|
string rewardGrantRowSchemaPath,
|
||||||
|
string questSkillXpGrantSchemaPath,
|
||||||
|
string questRewardBundleSchemaPath,
|
||||||
string questDefSchemaPath)
|
string questDefSchemaPath)
|
||||||
{
|
{
|
||||||
if (_questDefSchema is not null)
|
if (_questDefSchema is not null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
|
||||||
|
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
|
||||||
|
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);
|
||||||
_questObjectiveDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questObjectiveDefSchemaPath);
|
_questObjectiveDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questObjectiveDefSchemaPath);
|
||||||
_questStepDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questStepDefSchemaPath);
|
_questStepDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questStepDefSchemaPath);
|
||||||
_questDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questDefSchemaPath);
|
_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)
|
## 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 |
|
| 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: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`**. |
|
| **`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.
|
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