neon-sprawl/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoade...

891 lines
43 KiB
C#

using System.Collections.Frozen;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionCatalogLoaderTests
{
private static readonly FrozenSet<string> KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds;
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds;
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 =
"""
{
"schemaVersion": 1,
"quests": [
{
"id": "prototype_quest_gather_intro",
"displayName": "Intro: Salvage Run",
"prerequisiteQuestIds": [],
"completionRewardBundle": {
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
},
"steps": [
{
"id": "gather_intro_step_salvage",
"displayName": "Gather scrap metal",
"objectives": [
{
"id": "gather_intro_obj_scrap",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 3
}
]
}
]
},
{
"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",
"displayName": "Refine scrap",
"objectives": [
{
"id": "refine_intro_obj_recipe",
"kind": "craft_recipe",
"recipeId": "refine_scrap_standard",
"quantity": 1
}
]
}
]
},
{
"id": "prototype_quest_combat_intro",
"displayName": "Intro: Clear the Pocket",
"prerequisiteQuestIds": [],
"completionRewardBundle": {
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
},
"steps": [
{
"id": "combat_intro_step_encounter",
"displayName": "Clear the combat pocket",
"objectives": [
{
"id": "combat_intro_obj_encounter",
"kind": "encounter_complete",
"encounterId": "prototype_combat_pocket"
}
]
}
]
},
{
"id": "prototype_quest_operator_chain",
"displayName": "Operator Chain",
"prerequisiteQuestIds": [
"prototype_quest_gather_intro",
"prototype_quest_refine_intro",
"prototype_quest_combat_intro"
],
"completionRewardBundle": {
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }],
"reputationGrants": [
{ "factionId": "prototype_faction_grid_operators", "amount": 15 }
]
},
"steps": [
{
"id": "chain_step_gather",
"displayName": "Bulk salvage",
"objectives": [
{
"id": "chain_obj_gather",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 5
}
]
},
{
"id": "chain_step_refine",
"displayName": "Refine stock",
"objectives": [
{
"id": "chain_obj_refine",
"kind": "craft_recipe",
"recipeId": "refine_scrap_standard",
"quantity": 1
}
]
},
{
"id": "chain_step_stim",
"displayName": "Craft field stim",
"objectives": [
{
"id": "chain_obj_stim",
"kind": "craft_recipe",
"recipeId": "make_field_stim_mk0",
"quantity": 1
}
]
},
{
"id": "chain_step_token",
"displayName": "Hand off contract token",
"objectives": [
{
"id": "chain_obj_token",
"kind": "inventory_has_item",
"itemId": "contract_handoff_token",
"quantity": 1
}
]
}
]
},
{
"id": "prototype_quest_grid_contract",
"displayName": "Grid Contract",
"prerequisiteQuestIds": ["prototype_quest_operator_chain"],
"factionGateRules": [
{
"factionId": "prototype_faction_grid_operators",
"minStanding": 15
}
],
"completionRewardBundle": {
"reputationGrants": [
{ "factionId": "prototype_faction_rust_collective", "amount": 10 }
]
},
"steps": [
{
"id": "grid_contract_step_kit",
"displayName": "Deliver survey drone kit",
"objectives": [
{
"id": "grid_contract_obj_kit",
"kind": "inventory_has_item",
"itemId": "survey_drone_kit",
"quantity": 1
}
]
}
]
}
]
}
""";
private static (string Root, string QuestsDir, string DefSchemaPath, string StepSchemaPath, string ObjectiveSchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-questcat-");
var questsDir = Path.Combine(root.FullName, "content", "quests");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(questsDir);
Directory.CreateDirectory(schemaDir);
var defSchemaPath = Path.Combine(schemaDir, "quest-def.schema.json");
var stepSchemaPath = Path.Combine(schemaDir, "quest-step-def.schema.json");
var objectiveSchemaPath = Path.Combine(schemaDir, "quest-objective-def.schema.json");
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);
File.Copy(QuestCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true);
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
}
private static void WriteCatalog(string questsDir, string catalogJson) =>
File.WriteAllText(Path.Combine(questsDir, "prototype_quests.json"), catalogJson, Encoding.UTF8);
private static JsonObject GetQuestRow(JsonObject catalogRoot, string questId)
{
var quests = catalogRoot["quests"] as JsonArray
?? throw new InvalidOperationException("expected quests array");
foreach (var node in quests)
{
if (node is JsonObject row && row["id"]?.GetValue<string>() == questId)
return row;
}
throw new InvalidOperationException($"quest id not found: {questId}");
}
private static QuestDefinitionCatalog LoadCatalog(
string questsDir,
string defSchemaPath,
string stepSchemaPath,
string objectiveSchemaPath,
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null,
IReadOnlySet<string>? knownFactionIds = null) =>
QuestDefinitionCatalogLoader.Load(
questsDir,
defSchemaPath,
stepSchemaPath,
objectiveSchemaPath,
QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(questsDir),
QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(questsDir),
QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(questsDir),
KnownItemIds,
KnownRecipeIds,
KnownEncounterIds,
knownFactionIds ?? KnownFactionIds,
skillDefsById ?? RepoSkillDefs,
NullLogger.Instance);
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile()
{
// Arrange
var questsDir = QuestCatalogTestPaths.DiscoverRepoQuestsDirectory();
var defSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath();
var stepSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath();
var objectiveSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath();
// Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
WriteCatalog(questsDir, ValidPrototypeCatalogJson);
// Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather);
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);
Assert.Empty(gather.PrerequisiteQuestIds);
Assert.Single(gather.Steps);
Assert.Equal("gather_item", gather.Steps[0].Objectives[0].Kind);
Assert.True(catalog.TryGetQuest("prototype_quest_operator_chain", out var chain));
Assert.NotNull(chain);
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);
Assert.True(catalog.TryGetQuest(PrototypeE7M1QuestCatalogRules.GridContractQuestId, out var gridContract));
Assert.NotNull(gridContract);
Assert.Equal(
[PrototypeE7M1QuestCatalogRules.ChainQuestId],
gridContract!.PrerequisiteQuestIds);
Assert.NotNull(gridContract.CompletionRewardBundle);
Assert.Empty(gridContract.CompletionRewardBundle!.ItemGrants);
Assert.Empty(gridContract.CompletionRewardBundle.SkillXpGrants);
}
[Fact]
public void Load_ShouldThrow_WhenQuestsIsNotArray()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(questsDir, "bad_quests.json"),
"""{"schemaVersion": 1, "quests": "nope"}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_quests.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'quests' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(questsDir, "bad_quests.json"),
"""{"schemaVersion": 2, "quests": []}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRowViolatesSchema()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
const string bad = """
{
"schemaVersion": 1,
"quests": [
{
"id": "prototype_quest_gather_intro",
"displayName": "",
"prerequisiteQuestIds": [],
"steps": [
{
"id": "gather_intro_step_salvage",
"displayName": "Gather scrap metal",
"objectives": [
{
"id": "gather_intro_obj_scrap",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 3
}
]
}
]
}
]
}
""";
File.WriteAllText(Path.Combine(questsDir, "bad_quests.json"), bad, Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_quests.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Quest catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateQuestIdAcrossFiles()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(questsDir, "extra_quests.json"),
"""
{
"schemaVersion": 1,
"quests": [
{
"id": "prototype_quest_gather_intro",
"displayName": "Duplicate",
"prerequisiteQuestIds": [],
"steps": [
{
"id": "dup_step",
"displayName": "Dup",
"objectives": [
{
"id": "dup_obj",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 1
}
]
}
]
}
]
}
""",
Encoding.UTF8);
WriteCatalog(questsDir, ValidPrototypeCatalogJson);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate quest id 'prototype_quest_gather_intro'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateStepIdWithinQuest()
{
// 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 steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
steps.Add(steps[0]!.DeepClone());
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate step id 'gather_intro_step_salvage'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateObjectiveIdWithinQuest()
{
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var firstStep = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = firstStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
objectives[0]!["id"] = "chain_obj_refine";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate objective id 'chain_obj_refine'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingFrozenQuestId()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var quests = root["quests"] as JsonArray ?? throw new InvalidOperationException("expected quests");
quests.RemoveAt(3);
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype quest roster expects exactly quest ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownPrerequisiteQuestId()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var refine = GetQuestRow(root, "prototype_quest_refine_intro");
refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_missing");
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prerequisiteQuestIds references unknown quest id", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenCyclicPrerequisiteQuestIds()
{
// 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["prerequisiteQuestIds"] = new JsonArray("prototype_quest_refine_intro");
var refine = GetQuestRow(root, "prototype_quest_refine_intro");
refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_gather_intro");
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("cyclic prerequisiteQuestIds", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownItemIdInObjective()
{
// 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 steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["itemId"] = "unknown_item_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("itemId 'unknown_item_id'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownRecipeIdInObjective()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var refine = GetQuestRow(root, "prototype_quest_refine_intro");
var steps = refine["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["recipeId"] = "unknown_recipe_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("recipeId 'unknown_recipe_id'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownEncounterIdInObjective()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var combat = GetQuestRow(root, "prototype_quest_combat_intro");
var steps = combat["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["encounterId"] = "unknown_encounter_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("encounterId 'unknown_encounter_id'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenChainTerminalObjectiveIsWrongKind()
{
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["kind"] = "craft_recipe";
obj["recipeId"] = "refine_scrap_standard";
obj["quantity"] = 1;
obj.Remove("itemId");
obj.Remove("encounterId");
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("terminal objective kind must be 'inventory_has_item'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenChainTerminalItemIsWrong()
{
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["itemId"] = "scrap_metal_bulk";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("terminal itemId must be 'contract_handoff_token'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenChainTerminalQuantityIsWrong()
{
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["quantity"] = 2;
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
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_WhenCompletionRewardBundleItemIdDoesNotMatchFreezeTable()
{
// 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_WhenCompletionRewardBundleSkillIdDoesNotMatchFreezeTable()
{
// 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_WhenFactionGateReferencesUnknownFaction()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var grid = GetQuestRow(root, "prototype_quest_grid_contract");
var gateRules = grid["factionGateRules"] as JsonArray
?? throw new InvalidOperationException("expected factionGateRules");
(gateRules[0] as JsonObject)!["factionId"] = "unknown_faction_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("factionGateRules[0]", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenReputationGrantReferencesUnknownFaction()
{
// 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 completionRewardBundle");
var grants = bundle["reputationGrants"] as JsonArray
?? throw new InvalidOperationException("expected reputationGrants");
(grants[0] as JsonObject)!["factionId"] = "unknown_faction_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("reputationGrants[0]", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenE7M3CompletionBundleFreezeDiverges()
{
// 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 completionRewardBundle");
var grants = bundle["reputationGrants"] as JsonArray
?? throw new InvalidOperationException("expected reputationGrants");
(grants[0] as JsonObject)!["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 E7M3 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenGridContractGateMinStandingDiverges()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var grid = GetQuestRow(root, "prototype_quest_grid_contract");
var gateRules = grid["factionGateRules"] as JsonArray
?? throw new InvalidOperationException("expected factionGateRules");
(gateRules[0] as JsonObject)!["minStanding"] = 10;
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("factionGateRules must be", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingSchemaFile()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
WriteCatalog(questsDir, ValidPrototypeCatalogJson);
File.Delete(defSchemaPath);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Host_ShouldResolveQuestCatalogFromDi()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/health");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var questCatalog = factory.Services.GetRequiredService<QuestDefinitionCatalog>();
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, questCatalog.DistinctQuestCount);
Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather);
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);
}
[Fact]
public void Host_ShouldFailStartup_WhenQuestsDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-quests-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir);
try
{
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:QuestsDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Quest catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(badDir))
Directory.Delete(badDir);
}
}
}