diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs index 043ab31..78f953f 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs @@ -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()); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs index 03595ae..d74eafc 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs @@ -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 KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds; + private static readonly IReadOnlyDictionary RepoSkillDefs = LoadRepoSkillDefs(); + + private static IReadOnlyDictionary 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? 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(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(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(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(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(RepoSkillDefs, StringComparer.Ordinal) + { + ["salvage"] = salvageNoMissionReward, + }; + // Act + var ex = Record.Exception(() => + LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath, skillDefs)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenMissingSchemaFile() { diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs new file mode 100644 index 0000000..ad74153 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs @@ -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; + +/// +/// Prototype E7M2 completion bundle gates (NEO-125), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M2_* and _prototype_e7m2_* gate functions. +/// +public static class PrototypeE7M2QuestCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND. + public const string MissionRewardSourceKind = "mission_reward"; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M2_COMPLETION_BUNDLES. + public static readonly FrozenDictionary ExpectedCompletionBundles = + new Dictionary(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); + + /// Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise . + public static string? TryGetCompletionBundlePresenceError(IReadOnlyDictionary 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; + } + + /// Returns a human-readable error if bundle contents diverge from the E7M2 freeze table, otherwise . + public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary 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; + } + + /// Returns a human-readable error when bundle cross-refs fail, otherwise . + public static string? TryGetCompletionBundleCrossRefError( + IReadOnlyDictionary rowsById, + IReadOnlyDictionary 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); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs index 96cec83..6b75aa0 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs @@ -93,4 +93,16 @@ public static class QuestCatalogPathResolution return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-objective-def.schema.json")); } + + /// Resolves JSON Schema path for a reward grant row (shared by reward tables and quest bundles). + public static string ResolveRewardGrantRowSchemaPath(string questsDirectory) => + Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "reward-grant-row.schema.json")); + + /// Resolves JSON Schema path for a quest skill XP grant row. + public static string ResolveQuestSkillXpGrantSchemaPath(string questsDirectory) => + Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json")); + + /// Resolves JSON Schema path for a quest completion reward bundle. + public static string ResolveQuestRewardBundleSchemaPath(string questsDirectory) => + Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-reward-bundle.schema.json")); } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs index 622e726..32b0430 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ public static class QuestCatalogServiceCollectionExtensions var itemCatalog = sp.GetRequiredService(); var recipeCatalog = sp.GetRequiredService(); var encounterCatalog = sp.GetRequiredService(); + var skillCatalog = sp.GetRequiredService(); var logger = sp.GetRequiredService() .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); }); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs index 9456b65..984e314 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs @@ -5,7 +5,8 @@ public sealed class QuestDefRow( string id, string displayName, IReadOnlyList prerequisiteQuestIds, - IReadOnlyList steps) + IReadOnlyList steps, + QuestRewardBundleRow? completionRewardBundle = null) { public string Id { get; } = id; @@ -14,4 +15,6 @@ public sealed class QuestDefRow( public IReadOnlyList PrerequisiteQuestIds { get; } = prerequisiteQuestIds; public IReadOnlyList Steps { get; } = steps; + + public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle; } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs index f570220..5d80031 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -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; -/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113). +/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113, NEO-125). 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 knownItemIds, IReadOnlySet knownRecipeIds, IReadOnlySet knownEncounterIds, + IReadOnlyDictionary 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(); @@ -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(); var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); 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 ParseItemGrantRows(JsonArray? array) + { + if (array is null || array.Count == 0) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject grantObj) + continue; + + var itemId = (grantObj["itemId"] as JsonValue)!.GetValue(); + var quantity = (grantObj["quantity"] as JsonValue)!.GetValue(); + rows.Add(new RewardGrantRow(itemId, quantity)); + } + + return rows; + } + + private static IReadOnlyList ParseSkillXpGrantRows(JsonArray? array) + { + if (array is null || array.Count == 0) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject grantObj) + continue; + + var skillId = (grantObj["skillId"] as JsonValue)!.GetValue(); + var amount = (grantObj["amount"] as JsonValue)!.GetValue(); + rows.Add(new QuestSkillXpGrantRow(skillId, amount)); + } + + return rows; } private static QuestStepDefRow ParseStep(JsonObject stepObj) @@ -345,15 +439,21 @@ public static class QuestDefinitionCatalogLoader } } - /// Registers objective → step → def in once per process (NEO-113). + /// Registers bundle + objective → step → def in once per process (NEO-113, NEO-125). 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); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs new file mode 100644 index 0000000..da7bd1c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs @@ -0,0 +1,8 @@ +using NeonSprawl.Server.Game.Encounters; + +namespace NeonSprawl.Server.Game.Quests; + +/// Composite completion rewards on (NEO-125). +public sealed record QuestRewardBundleRow( + IReadOnlyList ItemGrants, + IReadOnlyList SkillXpGrants); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestSkillXpGrantRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestSkillXpGrantRow.cs new file mode 100644 index 0000000..81d1912 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestSkillXpGrantRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Single skill XP row on a quest (NEO-125). +public sealed record QuestSkillXpGrantRow(string SkillId, int Amount); diff --git a/server/README.md b/server/README.md index 49c7b56..5d82864 100644 --- a/server/README.md +++ b/server/README.md @@ -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.