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.Localization; using OpenTheBox.Rendering; using OpenTheBox.Rendering.Panels; using OpenTheBox.Simulation; using OpenTheBox.Simulation.Actions; using OpenTheBox.Simulation.Events; using Spectre.Console; using Spectre.Console.Rendering; 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.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")] [InlineData("destiny")] 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")] [InlineData("destiny")] 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); } // ── French translation tag coverage ──────────────────────────────── [Theory] [InlineData("space")] [InlineData("medieval")] [InlineData("pirate")] [InlineData("contemporary")] [InlineData("sentimental")] [InlineData("prehistoric")] [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] [InlineData("destiny")] public void Adventure_FrenchTranslationCoversAllTags(string theme) { var enPath = Path.Combine(ContentRoot, "adventures", theme, "intro.lor"); var frPath = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor"); Assert.True(File.Exists(enPath), $"Missing EN script: {enPath}"); Assert.True(File.Exists(frPath), $"Missing FR translation: {frPath}"); // Extract tags from EN file: tags appear at end of lines as #tag-name var enContent = File.ReadAllLines(enPath); var tagRegex = new System.Text.RegularExpressions.Regex(@"#([a-z][a-z0-9_-]*)\b"); var enTags = new HashSet(); foreach (var line in enContent) { var trimmed = line.TrimStart(); if (trimmed.StartsWith("//")) continue; foreach (System.Text.RegularExpressions.Match m in tagRegex.Matches(line)) { enTags.Add(m.Groups[1].Value); } } // Extract tags from FR file: tags appear at start of lines as #tag-name var frContent = File.ReadAllLines(frPath); var frTags = new HashSet(); foreach (var line in frContent) { var trimmed = line.TrimStart(); if (trimmed.StartsWith("#")) { var match = tagRegex.Match(trimmed); if (match.Success) frTags.Add(match.Groups[1].Value); } } var missingInFr = enTags.Except(frTags).ToList(); Assert.True( missingInFr.Count == 0, $"[{theme}] {missingInFr.Count} EN tag(s) missing in FR translation:\n " + string.Join("\n ", missingInFr.OrderBy(t => t))); } // ── 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(); // 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 allCrafted = expectedCraftedItems.IsSubsetOf(seenDefinitionIds); if (allUIFeatures && allCosmetics && allAdventures && allResources && allLore && allStats && 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 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_ink_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()); } // ── Save Snapshot Generator ──────────────────────────────────────── /// /// Generates save files at key progression milestones for visual testing. /// Snapshots are saved to saves/ as snapshot_1.otb through snapshot_9.otb. /// Load them in-game with Ctrl+1..9 for instant visual testing. /// /// Run with: dotnet test --filter "GenerateSaveSnapshots" --logger "console;verbosity=detailed" /// [Fact] public void GenerateSaveSnapshots() { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); var simulation = new GameSimulation(registry, new Random(42)); var craftingEngine = new CraftingEngine(); var state = GameState.Create("SnapshotPlayer", Locale.FR); var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); // Define snapshot points: (box count threshold, slot name, description) var snapshotDefs = new (int boxes, string slot, string desc)[] { (10, "snapshot_1", "Very early game"), (30, "snapshot_2", "First meta unlocks"), (75, "snapshot_3", "Several UI panels"), (150, "snapshot_4", "Adventures + cosmetics"), (300, "snapshot_5", "Crafting + workshops"), (500, "snapshot_6", "Most features"), (750, "snapshot_7", "Near completion"), (1000, "snapshot_8", "Endgame"), (2000, "snapshot_9", "Post-endgame"), }; int nextSnapshotIdx = 0; int totalBoxesOpened = 0; int maxTarget = snapshotDefs[^1].boxes; var saveManager = new Persistence.SaveManager(); var report = new System.Text.StringBuilder(); report.AppendLine(); report.AppendLine("╔════════════════════════════════════════════════════════════╗"); report.AppendLine("║ SAVE SNAPSHOT GENERATOR ║"); report.AppendLine("╠════════════════════════════════════════════════════════════╣"); for (int i = 0; i < 15_000 && totalBoxesOpened < maxTarget; 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 }; simulation.ProcessAction(action, state); // 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); totalBoxesOpened++; // Check if we've hit a snapshot point if (nextSnapshotIdx < snapshotDefs.Length && totalBoxesOpened >= snapshotDefs[nextSnapshotIdx].boxes) { var (_, slot, desc) = snapshotDefs[nextSnapshotIdx]; state.TotalBoxesOpened = totalBoxesOpened; saveManager.Save(state, slot); int uiCount = state.UnlockedUIFeatures.Count; int cosCount = state.UnlockedCosmetics.Count; int advCount = state.UnlockedAdventures.Count; int wsCount = state.UnlockedWorkstations.Count; int invCount = state.Inventory.GroupBy(x => x.DefinitionId).Count(); report.AppendLine($"║ Ctrl+{nextSnapshotIdx + 1}: {slot,-14} box #{totalBoxesOpened,-5}" + $" UI:{uiCount,2} Cos:{cosCount,2} Adv:{advCount,2} WS:{wsCount,2} Inv:{invCount,3} ║"); report.AppendLine($"║ {desc,-56}║"); nextSnapshotIdx++; } } report.AppendLine("╠════════════════════════════════════════════════════════════╣"); report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-38}║"); report.AppendLine($"║ Snapshots generated: {nextSnapshotIdx,-37}║"); report.AppendLine("╚════════════════════════════════════════════════════════════╝"); Console.WriteLine(report.ToString()); Assert.True(nextSnapshotIdx == snapshotDefs.Length, $"Expected {snapshotDefs.Length} snapshots but only generated {nextSnapshotIdx}. " + $"Game ran out of boxes at {totalBoxesOpened}."); } // ── Playthrough Capture ───────────────────────────────────────────── /// /// Simulates a game playthrough and captures the rendered output at each step. /// Outputs a detailed report with the action taken and full panel rendering. /// Run with: dotnet test --filter "PlaythroughCapture" --logger "console;verbosity=detailed" /// [Theory] [InlineData(42, 15)] [InlineData(777, 15)] public void PlaythroughCapture(int seed, int steps) { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); var simulation = new GameSimulation(registry, new Random(seed)); var craftingEngine = new CraftingEngine(); var loc = new LocalizationManager(Locale.FR); var state = GameState.Create("TestPlayer", Locale.FR); var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); var report = new System.Text.StringBuilder(); report.AppendLine(); report.AppendLine($"╔═══════════════════════════════════════════════════════════════╗"); report.AppendLine($"║ PLAYTHROUGH CAPTURE — seed={seed}, {steps} steps ║"); report.AppendLine($"╚═══════════════════════════════════════════════════════════════╝"); for (int step = 1; step <= steps; step++) { // ── Render current game state panels ── var context = RenderContext.FromGameState(state); string panelOutput = RenderGameStatePanels(state, context, registry, loc); report.AppendLine(); report.AppendLine($"┌─── Step {step} ─── Boxes: {state.TotalBoxesOpened} | UI: {state.UnlockedUIFeatures.Count} | Inv: {state.Inventory.Count} ───"); report.AppendLine(panelOutput); // ── Find a box to open ── var box = state.Inventory.FirstOrDefault(item => registry.IsBox(item.DefinitionId)); if (box is null) { report.AppendLine("│ ACTION: No boxes left — stopping."); report.AppendLine("└───────────────────────────────────────"); break; } var boxDef = registry.GetBox(box.DefinitionId); string boxName = boxDef is not null ? loc.Get(boxDef.NameKey) : box.DefinitionId; report.AppendLine($"│ ACTION: Open box \"{boxName}\""); // ── Open the box ── var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; var events = simulation.ProcessAction(action, state); // ── Log events ── foreach (var evt in events) { switch (evt) { case BoxOpenedEvent boe: var bd = registry.GetBox(boe.BoxId); string bn = bd is not null ? loc.Get(bd.NameKey) : boe.BoxId; if (!boe.IsAutoOpen) report.AppendLine($"│ EVENT: BoxOpened \"{bn}\""); break; case ItemReceivedEvent ire: var idef = registry.GetItem(ire.Item.DefinitionId); var iboxDef = idef is null ? registry.GetBox(ire.Item.DefinitionId) : null; string iname = idef is not null ? loc.Get(idef.NameKey) : iboxDef is not null ? loc.Get(iboxDef.NameKey) : ire.Item.DefinitionId; var rarityEnum = idef?.Rarity ?? iboxDef?.Rarity ?? ItemRarity.Common; string rarity = loc.Get($"rarity.{rarityEnum.ToString().ToLower()}"); report.AppendLine($"│ EVENT: Received [{rarity}] \"{iname}\""); break; case UIFeatureUnlockedEvent ufe: context.Unlock(ufe.Feature); report.AppendLine($"│ EVENT: ★ UI Feature unlocked: {ufe.Feature}"); break; case AdventureUnlockedEvent aue: report.AppendLine($"│ EVENT: Adventure unlocked: {aue.Theme}"); break; case ResourceChangedEvent rce: report.AppendLine($"│ EVENT: Resource {rce.Type}: {rce.OldValue} → {rce.NewValue}"); break; case CraftingStartedEvent cse: report.AppendLine($"│ EVENT: Crafting started: {cse.RecipeId} at {cse.Workstation}"); break; case InteractionTriggeredEvent ite: report.AppendLine($"│ EVENT: Interaction: {ite.DescriptionKey}"); break; case MessageEvent me: report.AppendLine($"│ EVENT: Message: {me.MessageKey}"); break; } } // ── 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); var coll = craftingEngine.CollectCompleted(state, registry); foreach (var ce in coll.OfType()) { var cd = registry.GetItem(ce.Item.DefinitionId); string cn = cd is not null ? loc.Get(cd.NameKey) : ce.Item.DefinitionId; report.AppendLine($"│ CRAFT: Collected \"{cn}\""); } var cascade = craftingEngine.AutoCraftCheck(state, registry); keepCrafting = cascade.OfType().Any(); } while (keepCrafting); state.TotalBoxesOpened++; report.AppendLine("└───────────────────────────────────────"); } // ── Final state rendering ── report.AppendLine(); report.AppendLine("╔═══════════════════════════════════════════════════════════════╗"); report.AppendLine($"║ FINAL STATE — {state.TotalBoxesOpened} boxes opened ║"); report.AppendLine("╚═══════════════════════════════════════════════════════════════╝"); var finalCtx = RenderContext.FromGameState(state); report.AppendLine(RenderGameStatePanels(state, finalCtx, registry, loc)); Console.WriteLine(report.ToString()); Assert.True(true); } /// /// Renders game state panels to a plain string (strips ANSI for readability). /// private static string RenderGameStatePanels( GameState state, RenderContext ctx, ContentRegistry registry, LocalizationManager loc) { var sb = new System.Text.StringBuilder(); var writer = new StringWriter(); var console = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(writer), Ansi = AnsiSupport.No, // plain text, no ANSI escapes ColorSystem = ColorSystemSupport.NoColors }); console.Profile.Width = SpectreRenderer.RefWidth; // Portrait if (ctx.HasPortraitPanel) { console.Write(PortraitPanel.Render(state.Appearance)); } // Stats if (ctx.HasStatsPanel) { console.Write(StatsPanel.Render(state, loc)); } // Resources if (ctx.HasResourcePanel) { console.Write(ResourcePanel.Render(state)); } // Inventory (compact) if (ctx.HasInventoryPanel) { console.Write(InventoryPanel.Render(state, registry, loc, compact: true)); } // Crafting if (ctx.HasCraftingPanel) { console.Write(CraftingPanel.Render(state, registry, loc)); } string output = writer.ToString(); if (!string.IsNullOrWhiteSpace(output)) { foreach (var line in output.Split('\n')) sb.AppendLine($"│ {line.TrimEnd()}"); } else { sb.AppendLine("│ (no panels unlocked yet)"); } return sb.ToString(); } // ── Inventory Render Capture ──────────────────────────────────────── /// /// Captures the interactive inventory rendering at multiple progression stages. /// Shows the inventory table with selection highlight and detail panels /// for each item type (consumable, lore, cosmetic, material). /// Run with: dotnet test --filter "InventoryRenderCapture" --logger "console;verbosity=detailed" /// [Fact] public void InventoryRenderCapture() { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); var simulation = new GameSimulation(registry, new Random(42)); var craftingEngine = new CraftingEngine(); var loc = new LocalizationManager(Locale.FR); var state = GameState.Create("InvPlayer", Locale.FR); var starterBox = ItemInstance.Create("box_starter"); state.AddItem(starterBox); var report = new System.Text.StringBuilder(); report.AppendLine(); report.AppendLine("╔═══════════════════════════════════════════════════════════════════╗"); report.AppendLine("║ INVENTORY RENDER CAPTURE — seed=42 ║"); report.AppendLine("╚═══════════════════════════════════════════════════════════════════╝"); // Snapshot points for inventory renders var capturePoints = new[] { 20, 50, 100, 200, 500 }; int nextCapture = 0; var writer = new StringWriter(); var console = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(writer), Ansi = AnsiSupport.No, ColorSystem = ColorSystemSupport.NoColors }); console.Profile.Width = SpectreRenderer.RefWidth; for (int i = 0; i < 5000 && state.TotalBoxesOpened < 500; 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 }; simulation.ProcessAction(action, state); // 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); state.TotalBoxesOpened++; if (nextCapture < capturePoints.Length && state.TotalBoxesOpened >= capturePoints[nextCapture]) { report.AppendLine(); report.AppendLine($"┌─── Box #{state.TotalBoxesOpened} ── Inventory: {state.Inventory.GroupBy(x => x.DefinitionId).Count()} types ───"); var grouped = InventoryPanel.GetGroupedItems(state, registry); // Render inventory with first item selected writer.GetStringBuilder().Clear(); console.Write(InventoryPanel.Render(state, registry, loc, selectedIndex: 0)); foreach (var line in writer.ToString().Split('\n')) report.AppendLine($"│ {line.TrimEnd()}"); // Render detail panel for first item if (grouped.Count > 0) { writer.GetStringBuilder().Clear(); var detail = InventoryPanel.RenderDetailPanel(grouped[0], registry, loc, state); if (detail is not null) { console.Write(detail); foreach (var line in writer.ToString().Split('\n')) report.AppendLine($"│ {line.TrimEnd()}"); } } // Show detail panel for specific item types if present var interestingTypes = new[] { ItemCategory.Box, ItemCategory.Consumable, ItemCategory.LoreFragment, ItemCategory.Cosmetic, ItemCategory.Material }; foreach (var cat in interestingTypes) { var sample = grouped.FirstOrDefault(g => g.Category == cat); if (sample is not null && (grouped.Count == 0 || sample != grouped[0])) { int idx = grouped.IndexOf(sample); report.AppendLine($"│ ── Detail for [{cat}]: {sample.DefId} ──"); writer.GetStringBuilder().Clear(); var detailAlt = InventoryPanel.RenderDetailPanel(sample, registry, loc, state); if (detailAlt is not null) { console.Write(detailAlt); foreach (var line in writer.ToString().Split('\n')) report.AppendLine($"│ {line.TrimEnd()}"); } } } report.AppendLine("└───────────────────────────────────────────────────────────────"); nextCapture++; } } Console.WriteLine(report.ToString()); Assert.True(nextCapture > 0, "No captures were taken — game may not have generated enough boxes."); } // ── 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)!; } // ══════════════════════════════════════════════════════════════════════════ // Item Utility Snapshot Test // ══════════════════════════════════════════════════════════════════════════ /// /// Generates a comprehensive inventory report of all game items, their categories, /// and all contexts where they can be used. Writes to tests/snapshots/item_utility_report.txt. /// In DEBUG mode the snapshot is always overwritten. In RELEASE mode the test fails if /// the content has changed (snapshot testing). /// [Fact] public void ItemUtilitySnapshot() { var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); var loc = new LocalizationManager(Locale.FR); var interactions = LoadInteractions(); // Build usage context maps var lootSources = new Dictionary>(); // itemId → [boxId, ...] var craftIngredientOf = new Dictionary>(); // itemId → [recipeId, ...] var craftOutputOf = new Dictionary(); // itemId → recipeId var interactionItems = new Dictionary>(); // itemId → [interactionId, ...] // Map loot sources foreach (var box in registry.Boxes.Values) { foreach (var guaranteed in box.LootTable.GuaranteedRolls) { if (!lootSources.ContainsKey(guaranteed)) lootSources[guaranteed] = []; lootSources[guaranteed].Add($"{box.Id}(G)"); } foreach (var entry in box.LootTable.Entries) { if (!lootSources.ContainsKey(entry.ItemDefinitionId)) lootSources[entry.ItemDefinitionId] = []; lootSources[entry.ItemDefinitionId].Add(box.Id); } } // Map crafting foreach (var recipe in registry.Recipes.Values) { craftOutputOf[recipe.Result.ItemDefinitionId] = recipe.Id; foreach (var ingredient in recipe.Ingredients) { if (!craftIngredientOf.ContainsKey(ingredient.ItemDefinitionId)) craftIngredientOf[ingredient.ItemDefinitionId] = []; craftIngredientOf[ingredient.ItemDefinitionId].Add(recipe.Id); } } // Map interactions (by required item IDs and tag matching) foreach (var interaction in interactions) { // Direct item ID requirements foreach (var reqId in interaction.RequiredItemIds ?? []) { if (!interactionItems.ContainsKey(reqId)) interactionItems[reqId] = []; interactionItems[reqId].Add(interaction.Id); } // Tag-based matching: find items that have ALL required tags if (interaction.RequiredItemTags.Count > 0) { foreach (var item in registry.Items.Values) { if (interaction.RequiredItemTags.All(tag => item.Tags.Contains(tag))) { if (!interactionItems.ContainsKey(item.Id)) interactionItems[item.Id] = []; if (!interactionItems[item.Id].Contains(interaction.Id)) interactionItems[item.Id].Add(interaction.Id); } } } } // Build workstation transformation map: itemId → [(recipeId, workstation), ...] var craftedAt = new Dictionary>(); foreach (var recipe in registry.Recipes.Values) { foreach (var ingredient in recipe.Ingredients) { if (!craftedAt.ContainsKey(ingredient.ItemDefinitionId)) craftedAt[ingredient.ItemDefinitionId] = []; craftedAt[ingredient.ItemDefinitionId].Add((recipe.Id, recipe.Workstation)); } if (!craftedAt.ContainsKey(recipe.Result.ItemDefinitionId)) craftedAt[recipe.Result.ItemDefinitionId] = []; craftedAt[recipe.Result.ItemDefinitionId].Add((recipe.Id, recipe.Workstation)); } // Build report var report = new System.Text.StringBuilder(); report.AppendLine("# Item Utility Report"); report.AppendLine($"# Total items: {registry.Items.Count}"); report.AppendLine($"# Total boxes: {registry.Boxes.Count}"); report.AppendLine($"# Total recipes: {registry.Recipes.Count}"); report.AppendLine(); // Group by category var categories = registry.Items.Values .GroupBy(i => i.Category) .OrderBy(g => g.Key.ToString()); foreach (var cat in categories) { report.AppendLine($"## {cat.Key} ({cat.Count()} items)"); report.AppendLine(new string('─', 80)); var sorted = cat.OrderByDescending(i => CountUsages(i, lootSources, craftIngredientOf, craftOutputOf, interactionItems)); foreach (var item in sorted) { string name = loc.Get(item.NameKey); var usages = new List(); // Loot source if (lootSources.TryGetValue(item.Id, out var sources)) usages.Add($"Loot: {string.Join(", ", sources.Distinct().Take(5))}{(sources.Count > 5 ? "..." : "")}"); // Consumable if (item.ResourceType.HasValue && item.ResourceAmount > 0) usages.Add($"Consume: +{item.ResourceAmount} {item.ResourceType}"); // Resource max increase (detected via tags) if (item.Tags.Contains("ResourceMax") && item.ResourceType.HasValue) usages.Add($"Upgrade: max {item.ResourceType}"); // Meta unlock if (item.MetaUnlock.HasValue) usages.Add($"Unlock: {item.MetaUnlock}"); // Workstation if (item.WorkstationType.HasValue) usages.Add($"Workstation: {item.WorkstationType}"); // Adventure theme if (item.AdventureTheme.HasValue) usages.Add($"Adventure: {item.AdventureTheme}"); // Cosmetic if (item.CosmeticSlot.HasValue) usages.Add($"Equip: {item.CosmeticSlot}={item.CosmeticValue}"); // Crafting ingredient (with workstation) if (craftIngredientOf.TryGetValue(item.Id, out var recipes)) { foreach (var recipeId in recipes) { var recipe = registry.Recipes[recipeId]; usages.Add($"Craft ingredient: {recipeId} @ {recipe.Workstation}"); } } // Crafting output (with workstation) if (craftOutputOf.TryGetValue(item.Id, out var outputRecipe)) { var recipe = registry.Recipes[outputRecipe]; usages.Add($"Craft output: {outputRecipe} @ {recipe.Workstation}"); } // Interaction if (interactionItems.TryGetValue(item.Id, out var inters)) usages.Add($"Interaction: {string.Join(", ", inters)}"); // Tags if (item.Tags.Contains("Music")) usages.Add("Ephemeral: plays melody"); if (item.Tags.Contains("Cookie")) usages.Add("Ephemeral: fortune message"); int usageCount = usages.Count; string usageIndicator = usageCount switch { 0 => "[NO USE]", 1 => "[*]", 2 => "[**]", 3 => "[***]", _ => $"[{"*".PadRight(usageCount, '*')}]" }; report.AppendLine($" {usageIndicator} {item.Id} ({item.Rarity}) — {name}"); foreach (var usage in usages) report.AppendLine($" {usage}"); report.AppendLine(); } } // Workstation summary: what each bench can craft report.AppendLine("## Workstations"); report.AppendLine(new string('─', 80)); var recipesByStation = registry.Recipes.Values .GroupBy(r => r.Workstation) .OrderBy(g => g.Key.ToString()); foreach (var stationGroup in recipesByStation) { report.AppendLine($" 🔨 {stationGroup.Key}"); foreach (var recipe in stationGroup.OrderBy(r => r.Id)) { var ingredientNames = recipe.Ingredients .Select(i => registry.Items.TryGetValue(i.ItemDefinitionId, out var iDef) ? $"{loc.Get(iDef.NameKey)} x{i.Quantity}" : $"{i.ItemDefinitionId} x{i.Quantity}"); var resultName = registry.Items.TryGetValue(recipe.Result.ItemDefinitionId, out var rDef) ? loc.Get(rDef.NameKey) : recipe.Result.ItemDefinitionId; report.AppendLine($" {recipe.Id}: {string.Join(" + ", ingredientNames)} → {resultName} x{recipe.Result.Quantity}"); } report.AppendLine(); } // Orphan check: items with no usage at all report.AppendLine("## Orphan Items (no usage context)"); report.AppendLine(new string('─', 80)); var orphans = registry.Items.Values .Where(i => CountUsages(i, lootSources, craftIngredientOf, craftOutputOf, interactionItems) == 0) .ToList(); if (orphans.Count == 0) report.AppendLine(" (none)"); else foreach (var orphan in orphans) report.AppendLine($" {orphan.Id} ({orphan.Category}, {orphan.Rarity})"); string reportText = report.ToString(); // Write snapshot to tests/snapshots/ relative to the test project file var testProjectDir = Path.GetDirectoryName(GetThisFilePath())!; var snapshotDir = Path.Combine(testProjectDir, "..", "snapshots"); Directory.CreateDirectory(snapshotDir); var snapshotPath = Path.Combine(snapshotDir, "item_utility_report.txt"); #if DEBUG File.WriteAllText(snapshotPath, reportText); Console.WriteLine(reportText); Assert.True(true, "Snapshot written (DEBUG mode)."); #else if (!File.Exists(snapshotPath)) { File.WriteAllText(snapshotPath, reportText); Assert.Fail("Snapshot did not exist — created. Re-run to validate."); } else { var existing = File.ReadAllText(snapshotPath); Assert.True(existing == reportText, "Item utility snapshot has changed. Review the diff and update the snapshot with a DEBUG build."); } #endif } private static int CountUsages( ItemDefinition item, Dictionary> lootSources, Dictionary> craftIngredientOf, Dictionary craftOutputOf, Dictionary> interactionItems) { int count = 0; if (lootSources.ContainsKey(item.Id)) count++; if (item.ResourceType.HasValue && item.ResourceAmount > 0) count++; if (item.Tags.Contains("ResourceMax") && item.ResourceType.HasValue) count++; if (item.MetaUnlock.HasValue) count++; if (item.WorkstationType.HasValue) count++; if (item.AdventureTheme.HasValue) count++; if (item.CosmeticSlot.HasValue) count++; if (craftIngredientOf.ContainsKey(item.Id)) count++; if (craftOutputOf.ContainsKey(item.Id)) count++; if (interactionItems.ContainsKey(item.Id)) count++; if (item.Tags.Contains("Music")) count++; if (item.Tags.Contains("Cookie")) count++; return count; } private static string GetThisFilePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; }