using System.Text.Json; using OpenTheBox.Core; using OpenTheBox.Core.Boxes; using OpenTheBox.Core.Crafting; using OpenTheBox.Core.Enums; using OpenTheBox.Core.Interactions; using OpenTheBox.Core.Items; using OpenTheBox.Data; using OpenTheBox.Simulation; using OpenTheBox.Simulation.Actions; using OpenTheBox.Simulation.Events; using Loreline; namespace OpenTheBox.Tests; /// /// Validates that all JSON content files deserialize correctly and are internally consistent. /// These tests catch data issues (typos, missing references, schema mismatches) before runtime. /// public class ContentValidationTests { private static readonly string ContentRoot = "content"; private static readonly string ItemsPath = Path.Combine(ContentRoot, "data", "items.json"); private static readonly string BoxesPath = Path.Combine(ContentRoot, "data", "boxes.json"); private static readonly string InteractionsPath = Path.Combine(ContentRoot, "data", "interactions.json"); private static readonly string RecipesPath = Path.Combine(ContentRoot, "data", "recipes.json"); private static readonly string EnStringsPath = Path.Combine(ContentRoot, "strings", "en.json"); private static readonly string FrStringsPath = Path.Combine(ContentRoot, "strings", "fr.json"); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }; // ── Items ──────────────────────────────────────────────────────────── [Fact] public void ItemsJson_Deserializes() { var json = File.ReadAllText(ItemsPath); var items = JsonSerializer.Deserialize>(json, JsonOptions); Assert.NotNull(items); Assert.NotEmpty(items); } [Fact] public void ItemsJson_AllIdsAreUnique() { var items = LoadItems(); var duplicates = items.GroupBy(i => i.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList(); Assert.Empty(duplicates); } [Fact] public void ItemsJson_AllHaveNameKeys() { var items = LoadItems(); var missing = items.Where(i => string.IsNullOrWhiteSpace(i.NameKey)).Select(i => i.Id).ToList(); Assert.Empty(missing); } [Fact] public void ItemsJson_AllHaveValidCategory() { var items = LoadItems(); // If deserialization succeeded with JsonStringEnumConverter, all categories are valid Assert.All(items, item => Assert.True(Enum.IsDefined(item.Category), $"Item '{item.Id}' has invalid category")); } [Fact] public void ItemsJson_AllHaveValidRarity() { var items = LoadItems(); Assert.All(items, item => Assert.True(Enum.IsDefined(item.Rarity), $"Item '{item.Id}' has invalid rarity")); } // ── Boxes ──────────────────────────────────────────────────────────── [Fact] public void BoxesJson_Deserializes() { var json = File.ReadAllText(BoxesPath); var boxes = JsonSerializer.Deserialize>(json, JsonOptions); Assert.NotNull(boxes); Assert.NotEmpty(boxes); } [Fact] public void BoxesJson_AllIdsAreUnique() { var boxes = LoadBoxes(); var duplicates = boxes.GroupBy(b => b.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList(); Assert.Empty(duplicates); } [Fact] public void BoxesJson_GuaranteedRollsReferenceValidItems() { var items = LoadItems().Select(i => i.Id).ToHashSet(); var boxes = LoadBoxes(); var boxIds = boxes.Select(b => b.Id).ToHashSet(); var invalid = new List(); foreach (var box in boxes) { foreach (var guaranteedId in box.LootTable.GuaranteedRolls) { if (!items.Contains(guaranteedId) && !boxIds.Contains(guaranteedId)) invalid.Add($"{box.Id} -> {guaranteedId}"); } } Assert.Empty(invalid); } [Fact] public void BoxesJson_LootEntryItemsExist() { var items = LoadItems().Select(i => i.Id).ToHashSet(); var boxes = LoadBoxes(); var boxIds = boxes.Select(b => b.Id).ToHashSet(); var invalid = new List(); foreach (var box in boxes) { foreach (var entry in box.LootTable.Entries) { if (!items.Contains(entry.ItemDefinitionId) && !boxIds.Contains(entry.ItemDefinitionId)) invalid.Add($"{box.Id} -> {entry.ItemDefinitionId}"); } } Assert.Empty(invalid); } [Fact] public void BoxesJson_AllEntriesHavePositiveWeight() { var boxes = LoadBoxes(); var invalid = new List(); foreach (var box in boxes) { foreach (var entry in box.LootTable.Entries) { if (entry.Weight <= 0) invalid.Add($"{box.Id} -> {entry.ItemDefinitionId} (weight={entry.Weight})"); } } Assert.Empty(invalid); } [Fact] public void BoxesJson_AllHaveEitherGuaranteedOrRollEntries() { var boxes = LoadBoxes(); var empty = boxes .Where(b => b.LootTable.GuaranteedRolls.Count == 0 && b.LootTable.Entries.Count == 0) .Select(b => b.Id) .ToList(); Assert.Empty(empty); } [Fact] public void BoxesJson_LootConditionsHaveValidTypes() { var boxes = LoadBoxes(); // If deserialization with JsonStringEnumConverter worked, all condition types are valid. // But let's also verify targetId makes sense for specific conditions. var issues = new List(); foreach (var box in boxes) { foreach (var entry in box.LootTable.Entries) { if (entry.Condition is not null) { Assert.True(Enum.IsDefined(entry.Condition.Type), $"Box '{box.Id}' entry '{entry.ItemDefinitionId}' has invalid condition type"); if (entry.Condition.Type == LootConditionType.BoxesOpenedAbove && !entry.Condition.Value.HasValue) { issues.Add($"{box.Id}/{entry.ItemDefinitionId}: BoxesOpenedAbove needs a value"); } } } } Assert.Empty(issues); } // ── Interactions ───────────────────────────────────────────────────── [Fact] public void InteractionsJson_Deserializes() { var json = File.ReadAllText(InteractionsPath); var rules = JsonSerializer.Deserialize>(json, JsonOptions); Assert.NotNull(rules); Assert.NotEmpty(rules); } [Fact] public void InteractionsJson_AllIdsAreUnique() { var rules = LoadInteractions(); var duplicates = rules.GroupBy(r => r.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList(); Assert.Empty(duplicates); } // ── Recipes ────────────────────────────────────────────────────────── [Fact] public void RecipesJson_Deserializes() { var json = File.ReadAllText(RecipesPath); var doc = JsonDocument.Parse(json); Assert.NotNull(doc); Assert.True(doc.RootElement.GetArrayLength() > 0, "recipes.json is empty"); } [Fact] public void RecipesJson_AllIngredientsExist() { var items = LoadItems().Select(i => i.Id).ToHashSet(); var boxes = LoadBoxes().Select(b => b.Id).ToHashSet(); var json = File.ReadAllText(RecipesPath); var doc = JsonDocument.Parse(json); var invalid = new List(); foreach (var recipe in doc.RootElement.EnumerateArray()) { var recipeId = recipe.GetProperty("id").GetString()!; foreach (var ingredient in recipe.GetProperty("ingredients").EnumerateArray()) { var itemId = ingredient.GetProperty("itemDefinitionId").GetString()!; if (!items.Contains(itemId) && !boxes.Contains(itemId)) invalid.Add($"{recipeId} -> ingredient '{itemId}'"); } var resultId = recipe.GetProperty("result").GetProperty("itemDefinitionId").GetString()!; if (!items.Contains(resultId) && !boxes.Contains(resultId)) invalid.Add($"{recipeId} -> result '{resultId}'"); } Assert.Empty(invalid); } [Fact] public void RecipesJson_AllWorkstationsAreValid() { var json = File.ReadAllText(RecipesPath); var doc = JsonDocument.Parse(json); var invalid = new List(); foreach (var recipe in doc.RootElement.EnumerateArray()) { var recipeId = recipe.GetProperty("id").GetString()!; var workstation = recipe.GetProperty("workstation").GetString()!; if (!Enum.TryParse(workstation, out _)) invalid.Add($"{recipeId} -> workstation '{workstation}'"); } Assert.Empty(invalid); } // ── Localization ───────────────────────────────────────────────────── [Fact] public void EnStrings_IsValidJson() { var json = File.ReadAllText(EnStringsPath); var dict = JsonSerializer.Deserialize>(json); Assert.NotNull(dict); Assert.NotEmpty(dict); } [Fact] public void FrStrings_IsValidJson() { var json = File.ReadAllText(FrStringsPath); var dict = JsonSerializer.Deserialize>(json); Assert.NotNull(dict); Assert.NotEmpty(dict); } [Fact] public void FrStrings_HasAllKeysFromEn() { var enJson = File.ReadAllText(EnStringsPath); var en = JsonSerializer.Deserialize>(enJson)!; var frJson = File.ReadAllText(FrStringsPath); var fr = JsonSerializer.Deserialize>(frJson)!; var missing = en.Keys.Where(k => !fr.ContainsKey(k)).ToList(); Assert.Empty(missing); } [Fact] public void ItemNameKeys_ExistInLocalization() { var items = LoadItems(); var en = LoadEnStrings(); var missing = items .Where(i => !en.ContainsKey(i.NameKey)) .Select(i => $"{i.Id} -> nameKey '{i.NameKey}'") .ToList(); Assert.Empty(missing); } [Fact] public void BoxNameKeys_ExistInLocalization() { var boxes = LoadBoxes(); var en = LoadEnStrings(); var missing = boxes .Where(b => !en.ContainsKey(b.NameKey)) .Select(b => $"{b.Id} -> nameKey '{b.NameKey}'") .ToList(); Assert.Empty(missing); } [Fact] public void BoxDescriptionKeys_ExistInLocalization() { var boxes = LoadBoxes(); var en = LoadEnStrings(); var missing = boxes .Where(b => !string.IsNullOrEmpty(b.DescriptionKey) && !en.ContainsKey(b.DescriptionKey)) .Select(b => $"{b.Id} -> descriptionKey '{b.DescriptionKey}'") .ToList(); Assert.Empty(missing); } [Fact] public void ItemNameKeys_ExistInFrLocalization() { var items = LoadItems(); var frJson = File.ReadAllText(FrStringsPath); var fr = JsonSerializer.Deserialize>(frJson)!; var missing = items .Where(i => !fr.ContainsKey(i.NameKey)) .Select(i => $"{i.Id} -> nameKey '{i.NameKey}'") .ToList(); Assert.Empty(missing); } [Fact] public void BoxNameKeys_ExistInFrLocalization() { var boxes = LoadBoxes(); var frJson = File.ReadAllText(FrStringsPath); var fr = JsonSerializer.Deserialize>(frJson)!; var missing = boxes .Where(b => !fr.ContainsKey(b.NameKey)) .Select(b => $"{b.Id} -> nameKey '{b.NameKey}'") .ToList(); Assert.Empty(missing); } [Fact] public void BoxDescriptionKeys_ExistInFrLocalization() { var boxes = LoadBoxes(); var frJson = File.ReadAllText(FrStringsPath); var fr = JsonSerializer.Deserialize>(frJson)!; var missing = boxes .Where(b => !string.IsNullOrEmpty(b.DescriptionKey) && !fr.ContainsKey(b.DescriptionKey)) .Select(b => $"{b.Id} -> descriptionKey '{b.DescriptionKey}'") .ToList(); Assert.Empty(missing); } [Fact] public void AllUIFeatures_HaveLocalizationKeys() { var en = LoadEnStrings(); // Map of UIFeature -> expected localization key var featureKeys = new Dictionary { [UIFeature.TextColors] = "meta.colors", [UIFeature.ExtendedColors] = "meta.extended_colors", [UIFeature.ArrowKeySelection] = "meta.arrows", [UIFeature.InventoryPanel] = "meta.inventory", [UIFeature.ResourcePanel] = "meta.resources", [UIFeature.StatsPanel] = "meta.stats", [UIFeature.PortraitPanel] = "meta.portrait", [UIFeature.ChatPanel] = "meta.chat", [UIFeature.FullLayout] = "meta.layout", [UIFeature.KeyboardShortcuts] = "meta.shortcuts", [UIFeature.BoxAnimation] = "meta.animation", [UIFeature.CraftingPanel] = "meta.crafting", [UIFeature.CompletionTracker] = "meta.completion", [UIFeature.AutoSave] = "meta.autosave", }; var missing = featureKeys .Where(kv => !en.ContainsKey(kv.Value)) .Select(kv => $"{kv.Key} -> '{kv.Value}'") .ToList(); Assert.Empty(missing); // Also verify every UIFeature enum value has a mapping var allFeatures = Enum.GetValues(); var unmapped = allFeatures.Where(f => !featureKeys.ContainsKey(f)).ToList(); Assert.Empty(unmapped); } [Fact] public void AllMetaUnlockItems_ReferenceValidUIFeatures() { var items = LoadItems(); var metaItems = items.Where(i => i.MetaUnlock.HasValue).ToList(); // Every meta item's MetaUnlock value should be a valid UIFeature // (this is guaranteed by deserialization, but let's verify the round-trip) Assert.All(metaItems, item => Assert.True(Enum.IsDefined(item.MetaUnlock!.Value), $"Item {item.Id} has invalid metaUnlock value")); } [Fact] public void CosmeticSlots_HaveLocalizationKeys() { var en = LoadEnStrings(); var items = LoadItems(); // Every CosmeticSlot used in items should have a cosmetic.slot.{slot} key var cosmeticSlots = items .Where(i => i.CosmeticSlot.HasValue) .Select(i => i.CosmeticSlot!.Value) .Distinct() .ToList(); var missingSlotKeys = cosmeticSlots .Select(s => $"cosmetic.slot.{s.ToString().ToLower()}") .Where(k => !en.ContainsKey(k)) .ToList(); Assert.Empty(missingSlotKeys); } [Fact] public void CosmeticItems_CanBeResolvedBySlotAndValue() { // Verifies the lookup pattern used in ChangeAppearance(): // For every cosmetic item, a lookup by (CosmeticSlot, CosmeticValue) must find the item var items = LoadItems(); var cosmeticItems = items .Where(i => i.CosmeticSlot.HasValue && i.CosmeticValue is not null) .ToList(); // Check for duplicate (slot, value) pairs which would cause ambiguous lookups var duplicates = cosmeticItems .GroupBy(i => (i.CosmeticSlot, Value: i.CosmeticValue!.ToLower())) .Where(g => g.Count() > 1) .Select(g => $"({g.Key.CosmeticSlot}, {g.Key.Value}): [{string.Join(", ", g.Select(i => i.Id))}]") .ToList(); Assert.Empty(duplicates); } [Fact] public void EnAndFr_HaveIdenticalKeysets() { var en = LoadEnStrings(); var frJson = File.ReadAllText(FrStringsPath); var fr = JsonSerializer.Deserialize>(frJson)!; var onlyInEn = en.Keys.Where(k => !fr.ContainsKey(k)).ToList(); var onlyInFr = fr.Keys.Where(k => !en.ContainsKey(k)).ToList(); Assert.Empty(onlyInEn); Assert.Empty(onlyInFr); } // ── ContentRegistry integration ────────────────────────────────────── [Fact] public void ContentRegistry_LoadsSuccessfully() { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); Assert.True(registry.Items.Count > 0, "No items loaded"); Assert.True(registry.Boxes.Count > 0, "No boxes loaded"); Assert.True(registry.InteractionRules.Count > 0, "No interaction rules loaded"); } [Fact] public void ContentRegistry_StarterBoxExists() { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var starter = registry.GetBox("box_starter"); Assert.NotNull(starter); Assert.NotEmpty(starter.LootTable.GuaranteedRolls); } // ── Simulation smoke test ──────────────────────────────────────────── [Fact] public void Simulation_OpenStarterBox_ProducesEvents() { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var simulation = new GameSimulation(registry, new Random(42)); var state = GameState.Create("TestPlayer", Locale.EN); // Give the player a starter box var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" }; var events = simulation.ProcessAction(action, state); Assert.NotEmpty(events); Assert.Contains(events, e => e is BoxOpenedEvent); Assert.Contains(events, e => e is ItemReceivedEvent); } // ── Black Box invariant: simulation owns all state mutations ───────── [Fact] public void Simulation_ProcessAction_HandlesAllStateMutations() { // The simulation (ProcessAction) must be the SOLE mutator of GameState. // After calling ProcessAction, the inventory should already reflect all // items received and consumed. External code (game loop, renderer) must // NOT call AddItem/RemoveItem — that would cause duplicates. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var simulation = new GameSimulation(registry, new Random(42)); var state = GameState.Create("TestPlayer", Locale.EN); var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); int inventoryBefore = state.Inventory.Count; // 1 (the starter box) var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" }; var events = simulation.ProcessAction(action, state); // Count events int received = events.OfType().Count(); int consumed = events.OfType().Count(); // The simulation already mutated state. Verify the inventory matches // exactly: initial - consumed + received int expectedCount = inventoryBefore - consumed + received; Assert.Equal(expectedCount, state.Inventory.Count); } [Fact] public void Simulation_NoDuplicateItemInstances_InInventory() { // Each ItemInstance has a unique Guid. After opening a box, // no two inventory entries should share the same Id. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); var simulation = new GameSimulation(registry, new Random(42)); var state = GameState.Create("TestPlayer", Locale.EN); var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" }; simulation.ProcessAction(action, state); var duplicateIds = state.Inventory .GroupBy(i => i.Id) .Where(g => g.Count() > 1) .Select(g => g.Key) .ToList(); Assert.Empty(duplicateIds); } // ── Adventures ─────────────────────────────────────────────────────── [Theory] [InlineData("space")] [InlineData("medieval")] [InlineData("pirate")] [InlineData("contemporary")] [InlineData("sentimental")] [InlineData("prehistoric")] [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] public void Adventure_ScriptFileExists(string theme) { var path = Path.Combine(ContentRoot, "adventures", theme, "intro.lor"); Assert.True(File.Exists(path), $"Missing adventure script: {path}"); } [Theory] [InlineData("space")] [InlineData("medieval")] [InlineData("pirate")] [InlineData("contemporary")] [InlineData("sentimental")] [InlineData("prehistoric")] [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] public void Adventure_FrenchTranslationExists(string theme) { var path = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor"); Assert.True(File.Exists(path), $"Missing French translation: {path}"); } // ── Adventure script parsing ─────────────────────────────────────── [Theory] [InlineData("space")] [InlineData("medieval")] [InlineData("pirate")] [InlineData("contemporary")] [InlineData("sentimental")] [InlineData("prehistoric")] [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] [InlineData("destiny")] public void Adventure_ScriptParsesWithoutError(string theme) { var path = Path.Combine(ContentRoot, "adventures", theme, "intro.lor"); Assert.True(File.Exists(path), $"Missing adventure script: {path}"); string content = File.ReadAllText(path); var exception = Record.Exception(() => { Script script = Engine.Parse( content, path, (importPath, callback) => { string dir = Path.GetDirectoryName(path) ?? "."; string fullPath = Path.Combine(dir, importPath); callback(File.Exists(fullPath) ? File.ReadAllText(fullPath) : string.Empty); }); Assert.NotNull(script); }); Assert.Null(exception); } [Theory] [InlineData("space")] [InlineData("medieval")] [InlineData("pirate")] [InlineData("contemporary")] [InlineData("sentimental")] [InlineData("prehistoric")] [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] public void Adventure_FrenchTranslationParsesWithoutError(string theme) { var path = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor"); Assert.True(File.Exists(path), $"Missing French translation: {path}"); string content = File.ReadAllText(path); var exception = Record.Exception(() => { Script script = Engine.Parse(content); Assert.NotNull(script); }); Assert.Null(exception); } // ── Full run integration tests ───────────────────────────────────── [Theory] [InlineData(42)] [InlineData(123)] [InlineData(777)] public void FullRun_AllReachableContentIsObtained(int seed) { // Simulates an entire game playthrough by repeatedly opening boxes // until all content reachable via box openings + crafting is unlocked. // Uses only the simulation (zero I/O) to prove game completability. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); var simulation = new GameSimulation(registry, new Random(seed)); var craftingEngine = new CraftingEngine(); var state = GameState.Create("CompletionTest", Locale.EN); // ── Compute the "reachable set" dynamically from item definitions ── var allItems = registry.Items.Values.ToList(); var expectedUIFeatures = allItems .Where(i => i.MetaUnlock.HasValue) .Select(i => i.MetaUnlock!.Value) .ToHashSet(); var expectedCosmetics = allItems .Where(i => i.CosmeticSlot.HasValue) .Select(i => i.Id) .ToHashSet(); var expectedAdventures = allItems .Where(i => i.AdventureTheme.HasValue) .Select(i => i.AdventureTheme!.Value) .ToHashSet(); var expectedResources = allItems .Where(i => i.ResourceType.HasValue) .Select(i => i.ResourceType!.Value) .ToHashSet(); var expectedLore = allItems .Where(i => i.Category == ItemCategory.LoreFragment) .Select(i => i.Id) .ToHashSet(); var expectedStats = allItems .Where(i => i.StatType.HasValue) .Select(i => i.StatType!.Value) .ToHashSet(); var expectedFonts = allItems .Where(i => i.FontStyle.HasValue) .Select(i => i.FontStyle!.Value) .ToHashSet(); // Adventure token recipes: their outputs are also direct drops from adventure boxes, // so they serve as bonus insurance, not mandatory crafting requirements. var adventureRecipeIds = new HashSet { "chart_star_navigation", "engrave_royal_seal", "enchant_dark_grimoire", "fuse_cosmic_crystal", "splice_glowing_dna", "preserve_amber" }; // Core recipe output IDs → required crafted items (only obtainable via crafting) var expectedCraftedItems = registry.Recipes.Values .Where(r => !adventureRecipeIds.Contains(r.Id)) .Select(r => r.Result.ItemDefinitionId) .ToHashSet(); // Track all unique item definition IDs ever received var seenDefinitionIds = new HashSet(); // ── Give starter box and run the game loop ── var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); const int maxBoxOpenings = 10_000; int totalBoxesOpened = 0; for (int i = 0; i < maxBoxOpenings; i++) { // Pick one box from inventory var box = state.Inventory .FirstOrDefault(item => registry.IsBox(item.DefinitionId)); if (box is null) break; var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; var events = simulation.ProcessAction(action, state); foreach (var evt in events.OfType()) seenDefinitionIds.Add(evt.Item.DefinitionId); // ── Fast-forward crafting: complete all jobs through full cascade ── bool keepCrafting; do { foreach (var job in state.ActiveCraftingJobs .Where(j => j.Status == CraftingJobStatus.InProgress)) job.StartedAt = DateTime.UtcNow.AddHours(-1); craftingEngine.TickJobs(state); var coll = craftingEngine.CollectCompleted(state, registry); foreach (var evt in coll.OfType()) seenDefinitionIds.Add(evt.Item.DefinitionId); var cascade = craftingEngine.AutoCraftCheck(state, registry); keepCrafting = cascade.OfType().Any(); } while (keepCrafting); totalBoxesOpened++; // Check if we've covered everything (including crafted items) bool allUIFeatures = expectedUIFeatures.IsSubsetOf(state.UnlockedUIFeatures); bool allCosmetics = expectedCosmetics.IsSubsetOf(state.UnlockedCosmetics); bool allAdventures = expectedAdventures.IsSubsetOf(state.UnlockedAdventures); bool allResources = expectedResources.IsSubsetOf(state.VisibleResources); bool allLore = expectedLore.IsSubsetOf(seenDefinitionIds); bool allStats = expectedStats.IsSubsetOf(state.VisibleStats); bool allFonts = expectedFonts.IsSubsetOf(state.AvailableFonts); bool allCrafted = expectedCraftedItems.IsSubsetOf(seenDefinitionIds); if (allUIFeatures && allCosmetics && allAdventures && allResources && allLore && allStats && allFonts && allCrafted) break; // 100% completion reached } // ── Assertions with detailed failure messages ── var missingUIFeatures = expectedUIFeatures.Except(state.UnlockedUIFeatures).ToList(); Assert.True(missingUIFeatures.Count == 0, $"Missing UI features after {totalBoxesOpened} boxes: {string.Join(", ", missingUIFeatures)}"); var missingCosmetics = expectedCosmetics.Except(state.UnlockedCosmetics).ToList(); Assert.True(missingCosmetics.Count == 0, $"Missing cosmetics after {totalBoxesOpened} boxes: {string.Join(", ", missingCosmetics)}"); var missingAdventures = expectedAdventures.Except(state.UnlockedAdventures).ToList(); Assert.True(missingAdventures.Count == 0, $"Missing adventures after {totalBoxesOpened} boxes: {string.Join(", ", missingAdventures)}"); var missingResources = expectedResources.Except(state.VisibleResources).ToList(); Assert.True(missingResources.Count == 0, $"Missing visible resources after {totalBoxesOpened} boxes: {string.Join(", ", missingResources)}"); var missingLore = expectedLore.Except(seenDefinitionIds).ToList(); Assert.True(missingLore.Count == 0, $"Missing lore fragments after {totalBoxesOpened} boxes: {string.Join(", ", missingLore)}"); var missingStats = expectedStats.Except(state.VisibleStats).ToList(); Assert.True(missingStats.Count == 0, $"Missing visible stats after {totalBoxesOpened} boxes: {string.Join(", ", missingStats)}"); var missingFonts = expectedFonts.Except(state.AvailableFonts).ToList(); Assert.True(missingFonts.Count == 0, $"Missing fonts after {totalBoxesOpened} boxes: {string.Join(", ", missingFonts)}"); var missingCrafted = expectedCraftedItems.Except(seenDefinitionIds).ToList(); Assert.True(missingCrafted.Count == 0, $"Missing crafted items after {totalBoxesOpened} boxes: {string.Join(", ", missingCrafted)}"); } [Fact] public void FullRun_GameLoopNeverBreaks() { // After 500 box openings, the player must still have at least 1 box // in inventory. This validates the box_of_boxes guaranteed roll // sustains the game loop indefinitely. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); var simulation = new GameSimulation(registry, new Random(123)); var state = GameState.Create("LoopTest", Locale.EN); var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); for (int i = 0; i < 500; i++) { var box = state.Inventory .FirstOrDefault(item => registry.IsBox(item.DefinitionId)); Assert.True(box is not null, $"No boxes left in inventory after opening {i} boxes. Game loop is broken."); var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; simulation.ProcessAction(action, state); } // After 500 openings, player should still have boxes var remainingBoxes = state.Inventory.Count(i => registry.IsBox(i.DefinitionId)); Assert.True(remainingBoxes > 0, "No boxes remaining after 500 openings. Game loop is unsustainable."); } [Theory] [InlineData(42)] [InlineData(123)] [InlineData(777)] public void FullRun_PacingReport(int seed) { // Diagnostic test: outputs a pacing report showing when each piece // of content is first unlocked, including crafting progression. var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); var simulation = new GameSimulation(registry, new Random(seed)); var craftingEngine = new CraftingEngine(); var state = GameState.Create("PacingTest", Locale.EN); var allItems = registry.Items.Values.ToList(); var expectedUIFeatures = allItems .Where(i => i.MetaUnlock.HasValue) .Select(i => i.MetaUnlock!.Value) .ToHashSet(); var expectedCosmetics = allItems .Where(i => i.CosmeticSlot.HasValue) .Select(i => i.Id) .ToHashSet(); var expectedAdventures = allItems .Where(i => i.AdventureTheme.HasValue) .Select(i => i.AdventureTheme!.Value) .ToHashSet(); var expectedResources = allItems .Where(i => i.ResourceType.HasValue) .Select(i => i.ResourceType!.Value) .ToHashSet(); var expectedLore = allItems .Where(i => i.Category == ItemCategory.LoreFragment) .Select(i => i.Id) .ToHashSet(); // Expected workstation blueprints (items with WorkstationType set) var expectedBlueprints = allItems .Where(i => i.WorkstationType.HasValue) .Select(i => i.Id) .ToHashSet(); // Adventure token recipes (bonus, not required for completion) var adventureRecipeIds = new HashSet { "chart_star_navigation", "engrave_royal_seal", "enchant_dark_grimoire", "fuse_cosmic_crystal", "splice_glowing_dna", "preserve_amber" }; // Expected crafted outputs (core recipes only — adventure outputs drop directly from boxes) var expectedCraftedItems = registry.Recipes.Values .Where(r => !adventureRecipeIds.Contains(r.Id)) .Select(r => r.Result.ItemDefinitionId) .ToHashSet(); var seenDefinitionIds = new HashSet(); // Track unlock milestones: (box#, description) var milestones = new List<(int boxNum, string description)>(); // Track previous counts to detect new unlocks int prevUI = 0, prevCos = 0, prevAdv = 0, prevRes = 0, prevLore = 0, prevBP = 0, prevCraft = 0; var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); const int maxBoxOpenings = 10_000; int totalBoxesOpened = 0; bool complete = false; for (int i = 0; i < maxBoxOpenings && !complete; i++) { var box = state.Inventory .FirstOrDefault(item => registry.IsBox(item.DefinitionId)); if (box is null) break; var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; var events = simulation.ProcessAction(action, state); foreach (var evt in events.OfType()) seenDefinitionIds.Add(evt.Item.DefinitionId); // ── Fast-forward crafting through full cascade ── bool keepCrafting; do { foreach (var job in state.ActiveCraftingJobs .Where(j => j.Status == CraftingJobStatus.InProgress)) job.StartedAt = DateTime.UtcNow.AddHours(-1); craftingEngine.TickJobs(state); var coll = craftingEngine.CollectCompleted(state, registry); foreach (var evt in coll.OfType()) seenDefinitionIds.Add(evt.Item.DefinitionId); var cascade = craftingEngine.AutoCraftCheck(state, registry); keepCrafting = cascade.OfType().Any(); } while (keepCrafting); totalBoxesOpened++; // Detect new unlocks int curUI = state.UnlockedUIFeatures.Count(f => expectedUIFeatures.Contains(f)); int curCos = state.UnlockedCosmetics.Count(c => expectedCosmetics.Contains(c)); int curAdv = state.UnlockedAdventures.Count(a => expectedAdventures.Contains(a)); int curRes = state.VisibleResources.Count(r => expectedResources.Contains(r)); int curLore = seenDefinitionIds.Count(id => expectedLore.Contains(id)); int curBP = state.UnlockedWorkstations.Count; int curCraft = seenDefinitionIds.Count(id => expectedCraftedItems.Contains(id)); if (curUI > prevUI) { var newFeatures = state.UnlockedUIFeatures.Where(f => expectedUIFeatures.Contains(f)).ToList(); milestones.Add((totalBoxesOpened, $"UI Feature {curUI}/{expectedUIFeatures.Count}: {newFeatures.Last()}")); } if (curBP > prevBP) { var newStation = state.UnlockedWorkstations.Last(); milestones.Add((totalBoxesOpened, $"Workshop {curBP}/{expectedBlueprints.Count}: {newStation}")); } if (curCraft > prevCraft) milestones.Add((totalBoxesOpened, $"Crafted: {curCraft}/{expectedCraftedItems.Count}")); if (curCos > prevCos) milestones.Add((totalBoxesOpened, $"Cosmetics: {curCos}/{expectedCosmetics.Count}")); if (curAdv > prevAdv) milestones.Add((totalBoxesOpened, $"Adventures: {curAdv}/{expectedAdventures.Count}")); if (curRes > prevRes) milestones.Add((totalBoxesOpened, $"Resources: {curRes}/{expectedResources.Count}")); if (curLore > prevLore) milestones.Add((totalBoxesOpened, $"Lore: {curLore}/{expectedLore.Count}")); prevUI = curUI; prevCos = curCos; prevAdv = curAdv; prevRes = curRes; prevLore = curLore; prevBP = curBP; prevCraft = curCraft; complete = curUI == expectedUIFeatures.Count && curCos == expectedCosmetics.Count && curAdv == expectedAdventures.Count && curRes == expectedResources.Count && curLore == expectedLore.Count && curCraft == expectedCraftedItems.Count; } // Build the pacing report var report = new System.Text.StringBuilder(); report.AppendLine(); report.AppendLine("╔════════════════════════════════════════════════════════════╗"); report.AppendLine($"║ PACING REPORT (seed={seed,-6}) ║"); report.AppendLine("╠════════════════════════════════════════════════════════════╣"); report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-38}║"); report.AppendLine($"║ Game completed: {(complete ? "YES" : "NO"),-42}║"); report.AppendLine("╠════════════════════════════════════════════════════════════╣"); report.AppendLine("║ Box# │ Milestone ║"); report.AppendLine("╟────────┼──────────────────────────────────────────────────╢"); foreach (var (boxNum, desc) in milestones) { report.AppendLine($"║ {boxNum,5} │ {desc,-48} ║"); } report.AppendLine("╠════════════════════════════════════════════════════════════╣"); // Summary by category with completion box# int uiDoneAt = milestones.LastOrDefault(m => m.description.Contains($"UI Feature {expectedUIFeatures.Count}/")).boxNum; int cosDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Cosmetics: {expectedCosmetics.Count}/")).boxNum; int advDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Adventures: {expectedAdventures.Count}/")).boxNum; int resDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Resources: {expectedResources.Count}/")).boxNum; int loreDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Lore: {expectedLore.Count}/")).boxNum; int bpDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Workshop {expectedBlueprints.Count}/")).boxNum; int craftDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Crafted: {expectedCraftedItems.Count}/")).boxNum; report.AppendLine($"║ UI Features ({expectedUIFeatures.Count,2}) complete at box #{uiDoneAt,-23}║"); report.AppendLine($"║ Workshops ({expectedBlueprints.Count,2}) complete at box #{bpDoneAt,-23}║"); report.AppendLine($"║ Crafted ({expectedCraftedItems.Count,2}) complete at box #{craftDoneAt,-23}║"); report.AppendLine($"║ Cosmetics ({expectedCosmetics.Count,2}) complete at box #{cosDoneAt,-23}║"); report.AppendLine($"║ Adventures ({expectedAdventures.Count,2}) complete at box #{advDoneAt,-23}║"); report.AppendLine($"║ Resources ({expectedResources.Count,2}) complete at box #{resDoneAt,-23}║"); report.AppendLine($"║ Lore ({expectedLore.Count,2}) complete at box #{loreDoneAt,-23}║"); report.AppendLine("╚════════════════════════════════════════════════════════════╝"); Assert.True(true, report.ToString()); Console.WriteLine(report.ToString()); } // ── Ultimate Ending Test ──────────────────────────────────────────── /// /// Simulates a full playthrough where the player completes all adventures, /// discovers all 9 secret branches, obtains the destiny token from box_endgame, /// and verifies the ultimate ending is achievable. /// [Theory] [InlineData(42)] [InlineData(123)] [InlineData(777)] public void FullRun_UltimateEnding_Achievable(int seed) { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); var simulation = new GameSimulation(registry, new Random(seed)); var craftingEngine = new CraftingEngine(); var state = GameState.Create("UltimateTest", Locale.EN); // All 9 secret branch IDs matching the adventure .lor scripts var allSecretBranches = new[] { "space_box_whisperer", "medieval_dragon_charmer", "pirate_one_of_us", "contemporary_vip", "sentimental_true_sight", "prehistoric_champion", "cosmic_enlightened", "microscopic_surgeon", "darkfantasy_blood_communion" }; // All 9 regular adventure themes (excluding Destiny) var regularThemes = Enum.GetValues() .Where(t => t != AdventureTheme.Destiny) .ToList(); // Phase 1: Open boxes until all resources are visible (triggers box_endgame availability) var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); const int maxBoxOpenings = 10_000; bool gotDestinyToken = false; for (int i = 0; i < maxBoxOpenings; i++) { var box = state.Inventory .FirstOrDefault(item => registry.IsBox(item.DefinitionId)); if (box is null) break; var events = simulation.ProcessAction( new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }, state); // Check if we received the destiny token foreach (var evt in events.OfType()) { if (evt.Item.DefinitionId == "destiny_token") gotDestinyToken = true; } // Fast-forward crafting bool keepCrafting; do { foreach (var job in state.ActiveCraftingJobs .Where(j => j.Status == CraftingJobStatus.InProgress)) job.StartedAt = DateTime.UtcNow.AddHours(-1); craftingEngine.TickJobs(state); craftingEngine.CollectCompleted(state, registry); var cascade = craftingEngine.AutoCraftCheck(state, registry); keepCrafting = cascade.OfType().Any(); } while (keepCrafting); } // Phase 2: Verify destiny token was obtained Assert.True(gotDestinyToken, "destiny_token should be obtainable from box_endgame after all resources are visible"); // Verify Destiny adventure is unlocked Assert.Contains(AdventureTheme.Destiny, state.UnlockedAdventures); // Phase 3: Simulate completing all regular adventures with secret branches foreach (var theme in regularThemes) { state.CompletedAdventures.Add(theme.ToString()); } foreach (var branchId in allSecretBranches) { state.CompletedSecretBranches.Add(branchId); } // Phase 4: Verify ultimate ending conditions Assert.Equal(9, state.CompletedSecretBranches.Count); Assert.Equal(regularThemes.Count, state.CompletedAdventures.Count); Assert.True( state.CompletedSecretBranches.Count >= Adventures.AdventureEngine.TotalSecretBranchThemes, $"All {Adventures.AdventureEngine.TotalSecretBranchThemes} secret branches should be found " + $"(got {state.CompletedSecretBranches.Count})"); // Phase 5: Verify the destiny adventure script exists string destinyScript = Path.Combine("content", "adventures", "destiny", "intro.lor"); Assert.True(File.Exists(destinyScript), $"Destiny adventure script should exist at {destinyScript}"); // Phase 6: Verify all secret branch IDs match the expected count Assert.Equal(Adventures.AdventureEngine.TotalSecretBranchThemes, allSecretBranches.Length); // Build report var report = new System.Text.StringBuilder(); report.AppendLine(); report.AppendLine("╔════════════════════════════════════════════════════════════╗"); report.AppendLine($"║ ULTIMATE ENDING REPORT (seed={seed,-6}) ║"); report.AppendLine("╠════════════════════════════════════════════════════════════╣"); report.AppendLine($"║ Destiny token obtained: {(gotDestinyToken ? "YES" : "NO"),-34}║"); report.AppendLine($"║ Destiny adventure unlocked: {(state.UnlockedAdventures.Contains(AdventureTheme.Destiny) ? "YES" : "NO"),-30}║"); report.AppendLine($"║ Adventures completed: {state.CompletedAdventures.Count,2}/{regularThemes.Count,-33}║"); report.AppendLine($"║ Secret branches found: {state.CompletedSecretBranches.Count,2}/{Adventures.AdventureEngine.TotalSecretBranchThemes,-32}║"); report.AppendLine($"║ Ultimate ending achievable: YES{' ',-27}║"); report.AppendLine("╠════════════════════════════════════════════════════════════╣"); report.AppendLine("║ Secret Branches: ║"); foreach (var branch in allSecretBranches) { bool found = state.CompletedSecretBranches.Contains(branch); report.AppendLine($"║ {(found ? "[x]" : "[ ]")} {branch,-52}║"); } report.AppendLine("╚════════════════════════════════════════════════════════════╝"); Console.WriteLine(report.ToString()); } // ── Helpers ────────────────────────────────────────────────────────── private static List LoadItems() { var json = File.ReadAllText(ItemsPath); return JsonSerializer.Deserialize>(json, JsonOptions)!; } private static List LoadBoxes() { var json = File.ReadAllText(BoxesPath); return JsonSerializer.Deserialize>(json, JsonOptions)!; } private static List LoadInteractions() { var json = File.ReadAllText(InteractionsPath); return JsonSerializer.Deserialize>(json, JsonOptions)!; } private static Dictionary LoadEnStrings() { var json = File.ReadAllText(EnStringsPath); return JsonSerializer.Deserialize>(json)!; } }