From 4c4d528187d17dfa5f60425fe311392ae4c81cdd Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Wed, 11 Mar 2026 09:34:30 +0100 Subject: [PATCH] Fix bugs, add endgame/completion features, adventure dedup, and comprehensive renderer tests Bug fixes: - Fix double "NEW FEATURE UNLOCKED" message and broken enum-to-key mapping for all UIFeatures - Fix Spectre markup crash when opening inventory with colors unlocked (unescaped rarity brackets) - Fix latent Spectre markup crash in ResourcePanel (unescaped bar brackets) - Fix WeightedRandom.PickMultiple picking with replacement causing duplicate drops - Fix double AddItem bug (BoxEngine + RenderEvents both adding to state) - Add StatType and FontStyle fields to ItemDefinition (were in JSON but missing from C# record) New features: - Endgame box (Mythic) that appears when all 8 resources are discovered, contains Crown of Completion - Completion percentage tracker as mid-game meta unlock (tier 3), shown in both renderers - Adventure pool depletion: adventure sub-boxes with already-unlocked themes are removed from loot pool - Global error handling with log file output (openthebox-error.log) - Tiered meta progression: 5 sequential meta boxes replacing single box_meta New tests (180 new, 228 total): - 5 panel rendering test classes covering all enum values (Portrait, Resource, Stats, Inventory, Chat) - RenderContext and RendererFactory logic tests - SpectreRenderer output tests across 4 context configurations - BasicRenderer output and input method tests - Simulation state mutation and full-run completion tests --- content/data/boxes.json | 114 +- content/data/items.json | 6 +- content/strings/en.json | 20 +- content/strings/fr.json | 20 +- src/OpenTheBox/Core/Boxes/BoxDefinition.cs | 3 +- .../Core/Enums/LootConditionType.cs | 5 +- src/OpenTheBox/Core/Enums/UIFeature.cs | 5 +- src/OpenTheBox/Core/Items/ItemDefinition.cs | 4 +- src/OpenTheBox/Program.cs | 86 +- src/OpenTheBox/Rendering/BasicRenderer.cs | 5 +- .../Rendering/Panels/ResourcePanel.cs | 2 +- src/OpenTheBox/Rendering/RenderContext.cs | 4 + src/OpenTheBox/Rendering/SpectreRenderer.cs | 12 +- src/OpenTheBox/Simulation/BoxEngine.cs | 38 + src/OpenTheBox/Simulation/MetaEngine.cs | 12 + src/OpenTheBox/Simulation/WeightedRandom.cs | 13 +- tests/OpenTheBox.Tests/RendererTests.cs | 1001 +++++++++++++++++ tests/OpenTheBox.Tests/UnitTest1.cs | 350 ++++++ 18 files changed, 1659 insertions(+), 41 deletions(-) create mode 100644 tests/OpenTheBox.Tests/RendererTests.cs diff --git a/content/data/boxes.json b/content/data/boxes.json index 34b9222..6f52940 100644 --- a/content/data/boxes.json +++ b/content/data/boxes.json @@ -33,7 +33,8 @@ {"itemDefinitionId": "box_supply", "weight": 1, "condition": {"type": "ResourceAbove", "targetId": "any", "value": 0}}, {"itemDefinitionId": "box_story", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 5}}, {"itemDefinitionId": "box_cookie", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 3}}, - {"itemDefinitionId": "box_music", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 10}} + {"itemDefinitionId": "box_music", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 10}}, + {"itemDefinitionId": "box_endgame", "weight": 1, "condition": {"type": "AllResourcesVisible"}} ] } }, @@ -80,7 +81,7 @@ {"itemDefinitionId": "cosmetic_eyes_green", "weight": 2}, {"itemDefinitionId": "tint_cyan", "weight": 2}, {"itemDefinitionId": "tint_orange", "weight": 2}, - {"itemDefinitionId": "box_meta", "weight": 1} + {"itemDefinitionId": "box_meta_basics", "weight": 1} ] } }, @@ -101,9 +102,10 @@ {"itemDefinitionId": "stamina_drink", "weight": 3}, {"itemDefinitionId": "cosmetic_hair_ponytail", "weight": 2}, {"itemDefinitionId": "cosmetic_eyes_sunglasses", "weight": 2}, + {"itemDefinitionId": "cosmetic_eyes_magician", "weight": 2}, {"itemDefinitionId": "cosmetic_body_sexy", "weight": 2}, {"itemDefinitionId": "tint_purple", "weight": 2}, - {"itemDefinitionId": "box_meta", "weight": 2}, + {"itemDefinitionId": "box_meta_basics", "weight": 2}, {"itemDefinitionId": "lore_1", "weight": 1}, {"itemDefinitionId": "lore_2", "weight": 1}, {"itemDefinitionId": "box_story", "weight": 1} @@ -117,7 +119,7 @@ "rarity": "Epic", "isAutoOpen": false, "lootTable": { - "guaranteedRolls": ["box_meta", "box_of_boxes"], + "guaranteedRolls": ["box_meta_basics", "box_of_boxes"], "rollCount": 3, "entries": [ {"itemDefinitionId": "material_titanium_raw", "weight": 2}, @@ -130,6 +132,7 @@ {"itemDefinitionId": "cosmetic_eyes_pilotglasses", "weight": 2}, {"itemDefinitionId": "cosmetic_body_suit", "weight": 2}, {"itemDefinitionId": "cosmetic_legs_pegleg", "weight": 2}, + {"itemDefinitionId": "cosmetic_arms_extrapair", "weight": 2}, {"itemDefinitionId": "tint_neon", "weight": 2}, {"itemDefinitionId": "tint_silver", "weight": 2}, {"itemDefinitionId": "lore_3", "weight": 1}, @@ -180,7 +183,7 @@ {"itemDefinitionId": "tint_rainbow", "weight": 1}, {"itemDefinitionId": "lore_6", "weight": 1}, {"itemDefinitionId": "lore_10", "weight": 1}, - {"itemDefinitionId": "box_meta", "weight": 2}, + {"itemDefinitionId": "box_meta_basics", "weight": 2}, {"itemDefinitionId": "box_black", "weight": 1}, {"itemDefinitionId": "box_music", "weight": 1}, {"itemDefinitionId": "box_story", "weight": 1} @@ -188,9 +191,9 @@ } }, { - "id": "box_meta", - "nameKey": "box.meta", - "descriptionKey": "box.meta.desc", + "id": "box_meta_basics", + "nameKey": "box.meta_basics", + "descriptionKey": "box.meta_basics.desc", "rarity": "Rare", "isAutoOpen": false, "lootTable": { @@ -198,17 +201,59 @@ "rollCount": 1, "entries": [ {"itemDefinitionId": "meta_colors", "weight": 5}, - {"itemDefinitionId": "meta_extended_colors", "weight": 3}, {"itemDefinitionId": "meta_arrows", "weight": 4}, {"itemDefinitionId": "meta_animation", "weight": 4}, + {"itemDefinitionId": "box_meta_interface", "weight": 1} + ] + } + }, + { + "id": "box_meta_interface", + "nameKey": "box.meta_interface", + "descriptionKey": "box.meta_interface.desc", + "rarity": "Rare", + "isAutoOpen": false, + "lootTable": { + "guaranteedRolls": ["box_of_boxes"], + "rollCount": 1, + "entries": [ {"itemDefinitionId": "meta_inventory", "weight": 3}, {"itemDefinitionId": "meta_resources", "weight": 3}, {"itemDefinitionId": "meta_stats", "weight": 3}, - {"itemDefinitionId": "meta_portrait", "weight": 2}, - {"itemDefinitionId": "meta_chat", "weight": 2}, - {"itemDefinitionId": "meta_layout", "weight": 1}, {"itemDefinitionId": "meta_shortcuts", "weight": 3}, - {"itemDefinitionId": "meta_crafting", "weight": 2}, + {"itemDefinitionId": "box_meta_deep", "weight": 1} + ] + } + }, + { + "id": "box_meta_deep", + "nameKey": "box.meta_deep", + "descriptionKey": "box.meta_deep.desc", + "rarity": "Epic", + "isAutoOpen": false, + "lootTable": { + "guaranteedRolls": ["box_of_boxes"], + "rollCount": 1, + "entries": [ + {"itemDefinitionId": "meta_extended_colors", "weight": 3}, + {"itemDefinitionId": "meta_crafting", "weight": 3}, + {"itemDefinitionId": "meta_chat", "weight": 3}, + {"itemDefinitionId": "meta_portrait", "weight": 2}, + {"itemDefinitionId": "meta_completion", "weight": 3}, + {"itemDefinitionId": "box_meta_resources", "weight": 1} + ] + } + }, + { + "id": "box_meta_resources", + "nameKey": "box.meta_resources", + "descriptionKey": "box.meta_resources.desc", + "rarity": "Epic", + "isAutoOpen": false, + "lootTable": { + "guaranteedRolls": ["box_of_boxes"], + "rollCount": 1, + "entries": [ {"itemDefinitionId": "meta_resource_health", "weight": 2}, {"itemDefinitionId": "meta_resource_mana", "weight": 2}, {"itemDefinitionId": "meta_resource_food", "weight": 2}, @@ -217,14 +262,30 @@ {"itemDefinitionId": "meta_resource_blood", "weight": 1}, {"itemDefinitionId": "meta_resource_oxygen", "weight": 1}, {"itemDefinitionId": "meta_resource_energy", "weight": 1}, + {"itemDefinitionId": "box_meta_mastery", "weight": 1} + ] + } + }, + { + "id": "box_meta_mastery", + "nameKey": "box.meta_mastery", + "descriptionKey": "box.meta_mastery.desc", + "rarity": "Legendary", + "isAutoOpen": false, + "lootTable": { + "guaranteedRolls": ["box_of_boxes"], + "rollCount": 1, + "entries": [ + {"itemDefinitionId": "meta_layout", "weight": 2}, {"itemDefinitionId": "meta_stat_strength", "weight": 1}, {"itemDefinitionId": "meta_stat_intelligence", "weight": 1}, {"itemDefinitionId": "meta_stat_luck", "weight": 1}, {"itemDefinitionId": "meta_stat_charisma", "weight": 1}, + {"itemDefinitionId": "meta_stat_dexterity", "weight": 1}, + {"itemDefinitionId": "meta_stat_wisdom", "weight": 1}, {"itemDefinitionId": "meta_font_consolas", "weight": 2}, {"itemDefinitionId": "meta_font_firetruc", "weight": 1}, - {"itemDefinitionId": "meta_font_jetbrains", "weight": 2}, - {"itemDefinitionId": "box_meta", "weight": 3} + {"itemDefinitionId": "meta_font_jetbrains", "weight": 2} ] } }, @@ -287,6 +348,7 @@ "descriptionKey": "box.adventure.space.desc", "rarity": "Rare", "isAutoOpen": false, + "adventureTheme": "Space", "lootTable": { "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, @@ -307,6 +369,7 @@ "descriptionKey": "box.adventure.medieval.desc", "rarity": "Rare", "isAutoOpen": false, + "adventureTheme": "Medieval", "lootTable": { "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, @@ -326,6 +389,7 @@ "descriptionKey": "box.adventure.pirate.desc", "rarity": "Rare", "isAutoOpen": false, + "adventureTheme": "Pirate", "lootTable": { "guaranteedRolls": ["pirate_map", "box_of_boxes"], "rollCount": 2, @@ -345,6 +409,7 @@ "descriptionKey": "box.adventure.contemporary.desc", "rarity": "Rare", "isAutoOpen": false, + "adventureTheme": "Contemporary", "lootTable": { "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, @@ -364,6 +429,7 @@ "descriptionKey": "box.adventure.sentimental.desc", "rarity": "Rare", "isAutoOpen": false, + "adventureTheme": "Sentimental", "lootTable": { "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, @@ -382,6 +448,7 @@ "descriptionKey": "box.adventure.prehistoric.desc", "rarity": "Rare", "isAutoOpen": false, + "adventureTheme": "Prehistoric", "lootTable": { "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, @@ -400,6 +467,7 @@ "descriptionKey": "box.adventure.cosmic.desc", "rarity": "Epic", "isAutoOpen": false, + "adventureTheme": "Cosmic", "lootTable": { "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, @@ -418,6 +486,7 @@ "descriptionKey": "box.adventure.microscopic.desc", "rarity": "Rare", "isAutoOpen": false, + "adventureTheme": "Microscopic", "lootTable": { "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, @@ -436,6 +505,7 @@ "descriptionKey": "box.adventure.darkfantasy.desc", "rarity": "Epic", "isAutoOpen": false, + "adventureTheme": "DarkFantasy", "lootTable": { "guaranteedRolls": ["box_of_boxes"], "rollCount": 2, @@ -509,7 +579,7 @@ {"itemDefinitionId": "material_diamond_gem", "weight": 1}, {"itemDefinitionId": "cosmic_core", "weight": 1}, {"itemDefinitionId": "darkfantasy_gem", "weight": 1}, - {"itemDefinitionId": "box_meta", "weight": 2}, + {"itemDefinitionId": "box_meta_basics", "weight": 2}, {"itemDefinitionId": "box_cookie", "weight": 2} ] } @@ -560,5 +630,17 @@ "rollCount": 0, "entries": [] } + }, + { + "id": "box_endgame", + "nameKey": "box.endgame", + "descriptionKey": "box.endgame.desc", + "rarity": "Mythic", + "isAutoOpen": false, + "lootTable": { + "guaranteedRolls": ["endgame_crown", "box_of_boxes"], + "rollCount": 0, + "entries": [] + } } ] diff --git a/content/data/items.json b/content/data/items.json index 8a07dc5..2432344 100644 --- a/content/data/items.json +++ b/content/data/items.json @@ -161,5 +161,9 @@ {"id": "resource_max_energy", "nameKey": "item.resource_max_energy", "category": "Consumable", "rarity": "Rare", "tags": ["Improvement", "ResourceMax"], "resourceType": "Energy", "resourceMaxIncrease": 10}, {"id": "music_melody", "nameKey": "item.music_melody", "category": "Consumable", "rarity": "Rare", "tags": ["Music", "Fun"], "description": "A melody plays from the box. Console.Beep never sounded so good."}, - {"id": "cookie_fortune", "nameKey": "item.cookie_fortune", "category": "Consumable", "rarity": "Common", "tags": ["Cookie", "Fun"], "description": "A fortune cookie with wisdom of questionable origin."} + {"id": "cookie_fortune", "nameKey": "item.cookie_fortune", "category": "Consumable", "rarity": "Common", "tags": ["Cookie", "Fun"], "description": "A fortune cookie with wisdom of questionable origin."}, + + {"id": "meta_completion", "nameKey": "meta.completion", "category": "Meta", "rarity": "Rare", "tags": ["Meta"], "metaUnlock": "CompletionTracker"}, + + {"id": "endgame_crown", "nameKey": "item.endgame_crown", "category": "Cosmetic", "rarity": "Mythic", "tags": ["Cosmetic", "Endgame"], "cosmeticSlot": "Hair", "cosmeticValue": "crown"} ] diff --git a/content/strings/en.json b/content/strings/en.json index f6dc3ac..c9c4fc0 100644 --- a/content/strings/en.json +++ b/content/strings/en.json @@ -41,6 +41,7 @@ "loot.rarity": "Rarity", "loot.category": "Category", "ui.feature_unlocked": "NEW FEATURE UNLOCKED: {0}", + "ui.completion": "Completion: {0}%", "prompt.what_do": "What do you do?", "prompt.invalid_choice": "Please enter a number between 1 and {0}.", @@ -68,8 +69,16 @@ "box.improvement.desc": "You can always improve. Especially with boxes.", "box.supply": "Supply Box", "box.supply.desc": "Supplies! The lifeblood of any box-opening enthusiast.", - "box.meta": "Meta Box", - "box.meta.desc": "This box improves... the way you see boxes. How meta.", + "box.meta_basics": "Meta Box - The Basics", + "box.meta_basics.desc": "Colors, arrows, animations. The foundation of seeing.", + "box.meta_interface": "Meta Box - The Interface", + "box.meta_interface.desc": "Panels, resources, stats. The tools of understanding.", + "box.meta_deep": "Meta Box - Customization", + "box.meta_deep.desc": "Extended colors, crafting, chat, portrait. Express yourself.", + "box.meta_resources": "Meta Box - Resources", + "box.meta_resources.desc": "Unlock the ability to see what you have. And what you lack.", + "box.meta_mastery": "Meta Box - Mastery", + "box.meta_mastery.desc": "Layout, stats, fonts. The final touches of a true box master.", "box.black": "Black Box", "box.black.desc": "Nobody knows what's inside. Not even the box.", "box.story": "Story Box", @@ -111,6 +120,7 @@ "meta.shortcuts": "Keyboard Shortcuts", "meta.animation": "Box Opening Animation", "meta.crafting": "Crafting Panel", + "meta.completion": "Completion Tracker", "item.rarity.common": "Common", "item.rarity.uncommon": "Uncommon", @@ -399,5 +409,9 @@ "recipe.craft_box_cool": "Craft Cool Box", "recipe.craft_box_supply": "Craft Supply Box", "recipe.craft_box_style": "Craft Style Box", - "recipe.craft_box_epic": "Craft Epic Box" + "recipe.craft_box_epic": "Craft Epic Box", + + "box.endgame": "The Final Box", + "box.endgame.desc": "You found all the resources. This is it. The last box. Are you ready?", + "item.endgame_crown": "Crown of Completion" } diff --git a/content/strings/fr.json b/content/strings/fr.json index 1ad5a63..fa453cf 100644 --- a/content/strings/fr.json +++ b/content/strings/fr.json @@ -41,6 +41,7 @@ "loot.rarity": "Rarete", "loot.category": "Categorie", "ui.feature_unlocked": "NOUVELLE FONCTIONNALITE : {0}", + "ui.completion": "Completion : {0}%", "prompt.what_do": "Que fais-tu ?", "prompt.invalid_choice": "Entre un nombre entre 1 et {0}.", @@ -68,8 +69,16 @@ "box.improvement.desc": "On peut toujours s'ameliorer. Surtout avec des boites.", "box.supply": "Boite de fourniture", "box.supply.desc": "Des fournitures ! Le sang vital de tout passione d'ouverture de boites.", - "box.meta": "Boite Meta", - "box.meta.desc": "Cette boite ameliore... la facon dont tu vois les boites. Trop meta.", + "box.meta_basics": "Boite Meta - Les Bases", + "box.meta_basics.desc": "Couleurs, fleches, animations. Le fondement de la vision.", + "box.meta_interface": "Boite Meta - L'Interface", + "box.meta_interface.desc": "Panneaux, ressources, stats. Les outils de la comprehension.", + "box.meta_deep": "Boite Meta - Personnalisation", + "box.meta_deep.desc": "Couleurs etendues, artisanat, chat, portrait. Exprime-toi.", + "box.meta_resources": "Boite Meta - Ressources", + "box.meta_resources.desc": "Deverouille la capacite de voir ce que tu as. Et ce qui te manque.", + "box.meta_mastery": "Boite Meta - La Maitrise", + "box.meta_mastery.desc": "Mise en page, stats, polices. Les touches finales d'un vrai maitre des boites.", "box.black": "Boite noire", "box.black.desc": "Personne ne sait ce qu'il y a dedans. Meme pas la boite.", "box.story": "Boite a histoire", @@ -111,6 +120,7 @@ "meta.shortcuts": "Raccourcis clavier", "meta.animation": "Animation d'ouverture de boite", "meta.crafting": "Panneau de fabrication", + "meta.completion": "Suivi de completion", "item.rarity.common": "Commun", "item.rarity.uncommon": "Peu commun", @@ -399,5 +409,9 @@ "recipe.craft_box_cool": "Fabriquer une boite coolos", "recipe.craft_box_supply": "Fabriquer une boite de fourniture", "recipe.craft_box_style": "Fabriquer une boite stylee", - "recipe.craft_box_epic": "Fabriquer une boite epique" + "recipe.craft_box_epic": "Fabriquer une boite epique", + + "box.endgame": "La Boite Finale", + "box.endgame.desc": "Tu as trouve toutes les ressources. C'est la derniere boite. Es-tu pret ?", + "item.endgame_crown": "Couronne d'Accomplissement" } diff --git a/src/OpenTheBox/Core/Boxes/BoxDefinition.cs b/src/OpenTheBox/Core/Boxes/BoxDefinition.cs index 0469872..d8b76ad 100644 --- a/src/OpenTheBox/Core/Boxes/BoxDefinition.cs +++ b/src/OpenTheBox/Core/Boxes/BoxDefinition.cs @@ -12,5 +12,6 @@ public sealed record BoxDefinition( ItemRarity Rarity, LootTable LootTable, bool IsAutoOpen, - List? RequiredItems = null + List? RequiredItems = null, + AdventureTheme? AdventureTheme = null ); diff --git a/src/OpenTheBox/Core/Enums/LootConditionType.cs b/src/OpenTheBox/Core/Enums/LootConditionType.cs index 7888e28..139ab3d 100644 --- a/src/OpenTheBox/Core/Enums/LootConditionType.cs +++ b/src/OpenTheBox/Core/Enums/LootConditionType.cs @@ -32,5 +32,8 @@ public enum LootConditionType HasAdventure, /// The player owns a specific cosmetic item. - HasCosmetic + HasCosmetic, + + /// All resource types are visible (discovered through meta progression). + AllResourcesVisible } diff --git a/src/OpenTheBox/Core/Enums/UIFeature.cs b/src/OpenTheBox/Core/Enums/UIFeature.cs index cef4c92..c495d02 100644 --- a/src/OpenTheBox/Core/Enums/UIFeature.cs +++ b/src/OpenTheBox/Core/Enums/UIFeature.cs @@ -41,5 +41,8 @@ public enum UIFeature BoxAnimation, /// Phase 7: Dedicated crafting panel for material transformation and item creation. - CraftingPanel + CraftingPanel, + + /// Phase 5: Shows completion percentage of all unique content. + CompletionTracker } diff --git a/src/OpenTheBox/Core/Items/ItemDefinition.cs b/src/OpenTheBox/Core/Items/ItemDefinition.cs index 8b99953..19d5e25 100644 --- a/src/OpenTheBox/Core/Items/ItemDefinition.cs +++ b/src/OpenTheBox/Core/Items/ItemDefinition.cs @@ -20,5 +20,7 @@ public sealed record ItemDefinition( MaterialType? MaterialType = null, MaterialForm? MaterialForm = null, WorkstationType? WorkstationType = null, - AdventureTheme? AdventureTheme = null + AdventureTheme? AdventureTheme = null, + StatType? StatType = null, + FontStyle? FontStyle = null ); diff --git a/src/OpenTheBox/Program.cs b/src/OpenTheBox/Program.cs index 119410b..2d6a175 100644 --- a/src/OpenTheBox/Program.cs +++ b/src/OpenTheBox/Program.cs @@ -23,14 +23,43 @@ public static class Program private static IRenderer _renderer = null!; private static bool _running = true; + private static readonly string LogFilePath = Path.Combine( + AppContext.BaseDirectory, "openthebox-error.log"); + public static async Task Main(string[] args) { - _saveManager = new SaveManager(); - _loc = new LocalizationManager(Locale.EN); - _renderContext = new RenderContext(); - _renderer = RendererFactory.Create(_renderContext, _loc); + try + { + _saveManager = new SaveManager(); + _loc = new LocalizationManager(Locale.EN); + _renderContext = new RenderContext(); + _renderer = RendererFactory.Create(_renderContext, _loc); - await MainMenuLoop(); + await MainMenuLoop(); + } + catch (Exception ex) + { + LogError(ex); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"A fatal error occurred. Details have been written to:"); + Console.WriteLine(LogFilePath); + Console.ResetColor(); + Console.WriteLine("Press any key to exit..."); + Console.ReadKey(intercept: true); + } + } + + private static void LogError(Exception ex) + { + try + { + var entry = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n\n"; + File.AppendAllText(LogFilePath, entry); + } + catch + { + // If logging itself fails, at least don't hide the original error + } } private static async Task MainMenuLoop() @@ -149,6 +178,7 @@ public static class Program while (_running) { _renderer.Clear(); + UpdateCompletionPercent(); _renderer.ShowGameState(_state, _renderContext); var actions = BuildActionList(); @@ -262,7 +292,6 @@ public static class Program // Skip loot reveal for auto-consumed items (auto-opening boxes) if (autoConsumedIds.Contains(itemEvt.Item.Id)) break; - _state.AddItem(itemEvt.Item); var itemDef = _registry.GetItem(itemEvt.Item.DefinitionId); var itemBoxDef = itemDef is null ? _registry.GetBox(itemEvt.Item.DefinitionId) : null; _renderer.ShowLootReveal( @@ -278,9 +307,8 @@ public static class Program case UIFeatureUnlockedEvent uiEvt: _renderContext.Unlock(uiEvt.Feature); _renderer = RendererFactory.Create(_renderContext, _loc); - var featureKey = $"meta.{uiEvt.Feature.ToString().ToLower()}"; _renderer.ShowUIFeatureUnlocked( - _loc.Get("meta.unlocked", _loc.Get(featureKey))); + _loc.Get(GetUIFeatureLocKey(uiEvt.Feature))); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); break; @@ -455,6 +483,48 @@ public static class Program /// /// Resolves the localized display name for any definition ID (item or box). /// + private static void UpdateCompletionPercent() + { + if (!_renderContext.HasCompletionTracker) return; + + var totalCosmetics = _registry.Items.Values.Count(i => i.CosmeticSlot.HasValue); + var totalAdventures = Enum.GetValues().Length; + var totalUIFeatures = Enum.GetValues().Length; + var totalResources = Enum.GetValues().Length; + var totalStats = Enum.GetValues().Length; + var totalFonts = Enum.GetValues().Length; + + var total = totalCosmetics + totalAdventures + totalUIFeatures + + totalResources + totalStats + totalFonts; + + var unlocked = _state.UnlockedCosmetics.Count + + _state.UnlockedAdventures.Count + + _state.UnlockedUIFeatures.Count + + _state.VisibleResources.Count + + _state.VisibleStats.Count + + _state.AvailableFonts.Count; + + _renderContext.CompletionPercent = total > 0 ? (int)(unlocked * 100.0 / total) : 0; + } + + private static string GetUIFeatureLocKey(UIFeature feature) => feature switch + { + 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", + _ => $"meta.{feature.ToString().ToLower()}" + }; + private static string GetLocalizedName(string definitionId) { var itemDef = _registry.GetItem(definitionId); diff --git a/src/OpenTheBox/Rendering/BasicRenderer.cs b/src/OpenTheBox/Rendering/BasicRenderer.cs index aab87ff..7df9113 100644 --- a/src/OpenTheBox/Rendering/BasicRenderer.cs +++ b/src/OpenTheBox/Rendering/BasicRenderer.cs @@ -66,7 +66,10 @@ public sealed class BasicRenderer(LocalizationManager loc) : IRenderer public void ShowGameState(GameState state, RenderContext context) { - // Phase 0: no panels unlocked yet, so nothing to show. + if (context.HasCompletionTracker) + { + Console.WriteLine(loc.Get("ui.completion", context.CompletionPercent)); + } } public void ShowAdventureDialogue(string? character, string text) diff --git a/src/OpenTheBox/Rendering/Panels/ResourcePanel.cs b/src/OpenTheBox/Rendering/Panels/ResourcePanel.cs index e32596f..fa4c608 100644 --- a/src/OpenTheBox/Rendering/Panels/ResourcePanel.cs +++ b/src/OpenTheBox/Rendering/Panels/ResourcePanel.cs @@ -36,7 +36,7 @@ public static class ResourcePanel string bar = new string('#', filled) + new string('-', empty); string color = GetResourceColor(resourceType); - rows.Add(new Markup($" [{color}]{Markup.Escape(label)}[/]: [{color}][{bar}][/] {current}/{max}")); + rows.Add(new Markup($" [{color}]{Markup.Escape(label)}[/]: [{color}][[{bar}]][/] {current}/{max}")); } if (rows.Count == 0) diff --git a/src/OpenTheBox/Rendering/RenderContext.cs b/src/OpenTheBox/Rendering/RenderContext.cs index 69ef735..16ddd38 100644 --- a/src/OpenTheBox/Rendering/RenderContext.cs +++ b/src/OpenTheBox/Rendering/RenderContext.cs @@ -36,6 +36,10 @@ public sealed class RenderContext public bool HasKeyboardShortcuts => Has(UIFeature.KeyboardShortcuts); public bool HasBoxAnimation => Has(UIFeature.BoxAnimation); public bool HasCraftingPanel => Has(UIFeature.CraftingPanel); + public bool HasCompletionTracker => Has(UIFeature.CompletionTracker); + + /// Completion percentage (0-100), updated before each render call. + public int CompletionPercent { get; set; } /// /// Builds a that mirrors the features already unlocked in a diff --git a/src/OpenTheBox/Rendering/SpectreRenderer.cs b/src/OpenTheBox/Rendering/SpectreRenderer.cs index 92a6cac..3055dc6 100644 --- a/src/OpenTheBox/Rendering/SpectreRenderer.cs +++ b/src/OpenTheBox/Rendering/SpectreRenderer.cs @@ -102,7 +102,7 @@ public sealed class SpectreRenderer : IRenderer foreach (var (name, rarity, category) in items) { string color = RarityColor(rarity); - AnsiConsole.MarkupLine($" - [{color}]{Markup.Escape(name)}[/] [{color}][{Markup.Escape(rarity)}][/] ({Markup.Escape(category)})"); + AnsiConsole.MarkupLine($" - [{color}]{Markup.Escape(name)}[/] [{color}][[{Markup.Escape(rarity)}]][/] ({Markup.Escape(category)})"); } } else @@ -382,6 +382,11 @@ public sealed class SpectreRenderer : IRenderer layout["Bottom"].Update(new Panel("[dim]???[/]").Header("???")); AnsiConsole.Write(layout); + + if (context.HasCompletionTracker) + { + AnsiConsole.Write(new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]").RuleStyle("cyan")); + } } /// @@ -413,5 +418,10 @@ public sealed class SpectreRenderer : IRenderer { AnsiConsole.Write(ChatPanel.Render([])); } + + if (context.HasCompletionTracker) + { + AnsiConsole.Write(new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]").RuleStyle("cyan")); + } } } diff --git a/src/OpenTheBox/Simulation/BoxEngine.cs b/src/OpenTheBox/Simulation/BoxEngine.cs index 6914c67..c8eee8f 100644 --- a/src/OpenTheBox/Simulation/BoxEngine.cs +++ b/src/OpenTheBox/Simulation/BoxEngine.cs @@ -34,6 +34,42 @@ public class BoxEngine(ContentRegistry registry) // Handle weighted random rolls var eligibleEntries = FilterEligibleEntries(boxDef.LootTable, state); + // Remove unique-unlock items the player already owns so they don't drop again + eligibleEntries.RemoveAll(e => + { + // Cosmetics: check by definition ID + if (state.UnlockedCosmetics.Contains(e.ItemDefinitionId)) + return true; + + // Check if this is a box with a known adventure theme already unlocked + var boxDef = registry.GetBox(e.ItemDefinitionId); + if (boxDef?.AdventureTheme is { } theme && state.UnlockedAdventures.Contains(theme)) + return true; + + // Look up item definition for field-based dedup + var itemDef = registry.GetItem(e.ItemDefinitionId); + if (itemDef is null) + return false; // Box entries and unknown entries survive the filter + + // Meta UI features already unlocked + if (itemDef.MetaUnlock.HasValue && state.UnlockedUIFeatures.Contains(itemDef.MetaUnlock.Value)) + return true; + + // Resource visibility already unlocked (only Meta items, not consumables) + if (itemDef.ResourceType.HasValue && itemDef.Category == ItemCategory.Meta + && state.VisibleResources.Contains(itemDef.ResourceType.Value)) + return true; + + // Stat visibility already unlocked + if (itemDef.StatType.HasValue && state.VisibleStats.Contains(itemDef.StatType.Value)) + return true; + + // Font already unlocked + if (itemDef.FontStyle.HasValue && state.AvailableFonts.Contains(itemDef.FontStyle.Value)) + return true; + + return false; + }); if (boxDef.LootTable.RollCount > 0 && eligibleEntries.Count > 0) { var weightedEntries = eligibleEntries @@ -111,6 +147,8 @@ public class BoxEngine(ContentRegistry registry) && state.UnlockedAdventures.Contains(adv), LootConditionType.HasCosmetic => condition.TargetId is not null && state.UnlockedCosmetics.Contains(condition.TargetId), + LootConditionType.AllResourcesVisible => + state.VisibleResources.Count >= Enum.GetValues().Length, _ => true }; } diff --git a/src/OpenTheBox/Simulation/MetaEngine.cs b/src/OpenTheBox/Simulation/MetaEngine.cs index af09d49..093cd14 100644 --- a/src/OpenTheBox/Simulation/MetaEngine.cs +++ b/src/OpenTheBox/Simulation/MetaEngine.cs @@ -49,6 +49,18 @@ public class MetaEngine state.UnlockedAdventures.Add(itemDef.AdventureTheme.Value); } + // Make stat type visible if this item references a stat + if (itemDef.StatType.HasValue) + { + state.VisibleStats.Add(itemDef.StatType.Value); + } + + // Unlock font if this item references a font style + if (itemDef.FontStyle.HasValue) + { + state.AvailableFonts.Add(itemDef.FontStyle.Value); + } + // Track cosmetic unlocks if (itemDef.CosmeticSlot.HasValue && itemDef.CosmeticValue is not null) { diff --git a/src/OpenTheBox/Simulation/WeightedRandom.cs b/src/OpenTheBox/Simulation/WeightedRandom.cs index 5ccb9be..990d2b3 100644 --- a/src/OpenTheBox/Simulation/WeightedRandom.cs +++ b/src/OpenTheBox/Simulation/WeightedRandom.cs @@ -33,13 +33,20 @@ public static class WeightedRandom } /// - /// Picks multiple items from a weighted list (with replacement) using the provided random source. + /// Picks multiple items from a weighted list (without replacement) using the provided random source. + /// If count exceeds available entries, returns one of each. /// public static List PickMultiple(IReadOnlyList<(T item, float weight)> entries, Random rng, int count) { var results = new List(count); - for (var i = 0; i < count; i++) - results.Add(Pick(entries, rng)); + var remaining = new List<(T item, float weight)>(entries); + + for (var i = 0; i < count && remaining.Count > 0; i++) + { + var picked = Pick(remaining, rng); + results.Add(picked); + remaining.RemoveAll(e => EqualityComparer.Default.Equals(e.item, picked)); + } return results; } diff --git a/tests/OpenTheBox.Tests/RendererTests.cs b/tests/OpenTheBox.Tests/RendererTests.cs new file mode 100644 index 0000000..2c01ad9 --- /dev/null +++ b/tests/OpenTheBox.Tests/RendererTests.cs @@ -0,0 +1,1001 @@ +using OpenTheBox.Core; +using OpenTheBox.Core.Characters; +using OpenTheBox.Core.Enums; +using OpenTheBox.Core.Items; +using OpenTheBox.Localization; +using OpenTheBox.Rendering; +using OpenTheBox.Rendering.Panels; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace OpenTheBox.Tests; + +// ── Helpers ────────────────────────────────────────────────────────────── + +/// +/// Renders an IRenderable to string using a local AnsiConsole. No shared state. +/// +static class RenderHelper +{ + public static string RenderToString(IRenderable renderable) + { + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor + }); + console.Write(renderable); + return writer.ToString(); + } +} + +/// +/// Captures AnsiConsole.Console and Console.Out output for renderer tests. +/// MUST NOT be used in parallel — use [Collection("ConsoleTests")]. +/// +sealed class ConsoleCapture : IDisposable +{ + private readonly IAnsiConsole _origAnsi; + private readonly TextWriter _origOut; + private readonly StringWriter _ansiWriter = new(); + private readonly StringWriter _consoleWriter = new(); + + public ConsoleCapture() + { + _origAnsi = AnsiConsole.Console; + _origOut = Console.Out; + + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(_ansiWriter), + Interactive = InteractionSupport.No, + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor + }); + Console.SetOut(_consoleWriter); + } + + public string Output => _ansiWriter.ToString() + _consoleWriter.ToString(); + + public void Dispose() + { + AnsiConsole.Console = _origAnsi; + Console.SetOut(_origOut); + } +} + +// ── Panel Tests ────────────────────────────────────────────────────────── + +public class PortraitPanelTests +{ + [Fact] + public void Render_DefaultAppearance_DoesNotThrow() + { + var result = RenderHelper.RenderToString(PortraitPanel.Render(new PlayerAppearance())); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_FullyEquipped_DoesNotThrow() + { + var appearance = new PlayerAppearance + { + HairStyle = HairStyle.Cyberpunk, + HairTint = TintColor.Neon, + EyeStyle = EyeStyle.CyberneticEyes, + BodyStyle = BodyStyle.Armored, + BodyTint = TintColor.Gold, + LegStyle = LegStyle.RocketBoots, + ArmStyle = ArmStyle.Wings + }; + var result = RenderHelper.RenderToString(PortraitPanel.Render(appearance)); + Assert.NotEmpty(result); + } + + [Theory] + [MemberData(nameof(AllHairStyles))] + public void Render_EachHairStyle_DoesNotThrow(HairStyle style) + { + var result = RenderHelper.RenderToString( + PortraitPanel.Render(new PlayerAppearance { HairStyle = style })); + Assert.NotEmpty(result); + } + + [Theory] + [MemberData(nameof(AllEyeStyles))] + public void Render_EachEyeStyle_DoesNotThrow(EyeStyle style) + { + var result = RenderHelper.RenderToString( + PortraitPanel.Render(new PlayerAppearance { EyeStyle = style })); + Assert.NotEmpty(result); + } + + [Theory] + [MemberData(nameof(AllBodyStyles))] + public void Render_EachBodyStyle_DoesNotThrow(BodyStyle style) + { + var result = RenderHelper.RenderToString( + PortraitPanel.Render(new PlayerAppearance { BodyStyle = style })); + Assert.NotEmpty(result); + } + + [Theory] + [MemberData(nameof(AllLegStyles))] + public void Render_EachLegStyle_DoesNotThrow(LegStyle style) + { + var result = RenderHelper.RenderToString( + PortraitPanel.Render(new PlayerAppearance { LegStyle = style })); + Assert.NotEmpty(result); + } + + [Theory] + [MemberData(nameof(AllArmStyles))] + public void Render_EachArmStyle_DoesNotThrow(ArmStyle style) + { + var result = RenderHelper.RenderToString( + PortraitPanel.Render(new PlayerAppearance { ArmStyle = style })); + Assert.NotEmpty(result); + } + + [Theory] + [MemberData(nameof(AllTintColors))] + public void Render_EachHairTint_DoesNotThrow(TintColor tint) + { + var result = RenderHelper.RenderToString( + PortraitPanel.Render(new PlayerAppearance { HairTint = tint })); + Assert.NotEmpty(result); + } + + [Theory] + [MemberData(nameof(AllTintColors))] + public void Render_EachBodyTint_DoesNotThrow(TintColor tint) + { + var result = RenderHelper.RenderToString( + PortraitPanel.Render(new PlayerAppearance { BodyTint = tint })); + Assert.NotEmpty(result); + } + + public static IEnumerable AllHairStyles() => + Enum.GetValues().Select(v => new object[] { v }); + public static IEnumerable AllEyeStyles() => + Enum.GetValues().Select(v => new object[] { v }); + public static IEnumerable AllBodyStyles() => + Enum.GetValues().Select(v => new object[] { v }); + public static IEnumerable AllLegStyles() => + Enum.GetValues().Select(v => new object[] { v }); + public static IEnumerable AllArmStyles() => + Enum.GetValues().Select(v => new object[] { v }); + public static IEnumerable AllTintColors() => + Enum.GetValues().Select(v => new object[] { v }); +} + +public class ResourcePanelTests +{ + [Fact] + public void Render_Empty_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + var result = RenderHelper.RenderToString(ResourcePanel.Render(state)); + Assert.NotEmpty(result); + } + + [Theory] + [MemberData(nameof(AllResourceTypes))] + public void Render_SingleResource_DoesNotThrow(ResourceType type) + { + var state = GameState.Create("Test", Locale.EN); + state.VisibleResources.Add(type); + state.Resources[type] = new ResourceState(50, 100); + var result = RenderHelper.RenderToString(ResourcePanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_AllResources_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + foreach (var rt in Enum.GetValues()) + { + state.VisibleResources.Add(rt); + state.Resources[rt] = new ResourceState(30 + (int)rt * 5, 100); + } + var result = RenderHelper.RenderToString(ResourcePanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_ZeroCurrent_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + state.VisibleResources.Add(ResourceType.Health); + state.Resources[ResourceType.Health] = new ResourceState(0, 100); + var result = RenderHelper.RenderToString(ResourcePanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_ZeroMax_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + state.VisibleResources.Add(ResourceType.Mana); + state.Resources[ResourceType.Mana] = new ResourceState(0, 0); + var result = RenderHelper.RenderToString(ResourcePanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_FullResource_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + state.VisibleResources.Add(ResourceType.Gold); + state.Resources[ResourceType.Gold] = new ResourceState(100, 100); + var result = RenderHelper.RenderToString(ResourcePanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_VisibleButNotInDict_Skipped() + { + var state = GameState.Create("Test", Locale.EN); + state.VisibleResources.Add(ResourceType.Energy); + // Not adding to Resources dict → should skip without crash + var result = RenderHelper.RenderToString(ResourcePanel.Render(state)); + Assert.NotEmpty(result); + } + + public static IEnumerable AllResourceTypes() => + Enum.GetValues().Select(v => new object[] { v }); +} + +public class StatsPanelTests +{ + [Fact] + public void Render_Empty_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + var result = RenderHelper.RenderToString(StatsPanel.Render(state)); + Assert.NotEmpty(result); + } + + [Theory] + [MemberData(nameof(AllStatTypes))] + public void Render_SingleStat_DoesNotThrow(StatType type) + { + var state = GameState.Create("Test", Locale.EN); + state.VisibleStats.Add(type); + state.Stats[type] = 42; + var result = RenderHelper.RenderToString(StatsPanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_AllStats_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + foreach (var st in Enum.GetValues()) + { + state.VisibleStats.Add(st); + state.Stats[st] = 10 + (int)st * 3; + } + state.TotalBoxesOpened = 999; + var result = RenderHelper.RenderToString(StatsPanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_HighBoxCount_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + state.TotalBoxesOpened = 999_999; + var result = RenderHelper.RenderToString(StatsPanel.Render(state)); + Assert.NotEmpty(result); + } + + public static IEnumerable AllStatTypes() => + Enum.GetValues().Select(v => new object[] { v }); +} + +public class InventoryPanelTests +{ + [Fact] + public void Render_Empty_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + var result = RenderHelper.RenderToString(InventoryPanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_SingleItem_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + state.AddItem(ItemInstance.Create("health_potion_small")); + var result = RenderHelper.RenderToString(InventoryPanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_GroupedItems_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + state.AddItem(ItemInstance.Create("health_potion_small")); + state.AddItem(ItemInstance.Create("health_potion_small")); + state.AddItem(ItemInstance.Create("mana_crystal_small")); + var result = RenderHelper.RenderToString(InventoryPanel.Render(state)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_WithLocalization_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + state.AddItem(ItemInstance.Create("health_potion_small")); + var loc = new LocalizationManager(Locale.EN); + var result = RenderHelper.RenderToString(InventoryPanel.Render(state, loc)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_ManyItems_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + for (int i = 0; i < 50; i++) + state.AddItem(ItemInstance.Create($"item_{i}")); + var result = RenderHelper.RenderToString(InventoryPanel.Render(state)); + Assert.NotEmpty(result); + } +} + +public class ChatPanelTests +{ + [Fact] + public void Render_Empty_DoesNotThrow() + { + var result = RenderHelper.RenderToString(ChatPanel.Render([])); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_NarrationOnly_DoesNotThrow() + { + var messages = new List<(string?, string)> { (null, "The door creaks open.") }; + var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_WithCharacter_DoesNotThrow() + { + var messages = new List<(string?, string)> { ("Guard", "Halt! Who goes there?") }; + var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_MixedMessages_DoesNotThrow() + { + var messages = new List<(string?, string)> + { + (null, "You enter the cave."), + ("Dragon", "Who dares disturb my slumber?"), + (null, "The ground shakes."), + ("Dragon", "Prepare yourself!") + }; + var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_OverflowMessages_ShowsOnlyLast10() + { + var messages = Enumerable.Range(0, 15) + .Select(i => ((string?)$"NPC{i}", $"Message {i}")) + .ToList(); + var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); + Assert.NotEmpty(result); + } + + [Fact] + public void Render_SpecialCharacters_DoesNotThrow() + { + var messages = new List<(string?, string)> + { + ("[Boss]", "I am [ultimate] {boss}!"), + (null, "A [mysterious] voice echoes.") + }; + var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); + Assert.NotEmpty(result); + } +} + +// ── RenderContext + RendererFactory Tests ──────────────────────────────── + +public class RenderContextTests +{ + [Theory] + [MemberData(nameof(AllUIFeatures))] + public void Unlock_SetsFeature(UIFeature feature) + { + var ctx = new RenderContext(); + Assert.False(ctx.Has(feature)); + ctx.Unlock(feature); + Assert.True(ctx.Has(feature)); + } + + [Fact] + public void CompletionPercent_DefaultsToZero() + { + var ctx = new RenderContext(); + Assert.Equal(0, ctx.CompletionPercent); + } + + [Fact] + public void CompletionPercent_CanBeSet() + { + var ctx = new RenderContext(); + ctx.CompletionPercent = 75; + Assert.Equal(75, ctx.CompletionPercent); + } + + [Fact] + public void FromGameState_MirrorsUnlockedFeatures() + { + var state = GameState.Create("Test", Locale.EN); + state.UnlockedUIFeatures.Add(UIFeature.TextColors); + state.UnlockedUIFeatures.Add(UIFeature.ArrowKeySelection); + + var ctx = RenderContext.FromGameState(state); + Assert.True(ctx.HasColors); + Assert.True(ctx.HasArrowSelection); + Assert.False(ctx.HasInventoryPanel); + } + + [Fact] + public void FromGameState_Empty_AllFalse() + { + var state = GameState.Create("Test", Locale.EN); + var ctx = RenderContext.FromGameState(state); + + Assert.False(ctx.HasColors); + Assert.False(ctx.HasExtendedColors); + Assert.False(ctx.HasArrowSelection); + Assert.False(ctx.HasInventoryPanel); + Assert.False(ctx.HasResourcePanel); + Assert.False(ctx.HasStatsPanel); + Assert.False(ctx.HasPortraitPanel); + Assert.False(ctx.HasChatPanel); + Assert.False(ctx.HasFullLayout); + Assert.False(ctx.HasKeyboardShortcuts); + Assert.False(ctx.HasBoxAnimation); + Assert.False(ctx.HasCraftingPanel); + Assert.False(ctx.HasCompletionTracker); + } + + public static IEnumerable AllUIFeatures() => + Enum.GetValues().Select(v => new object[] { v }); +} + +public class RendererFactoryTests +{ + [Fact] + public void Create_NoFeatures_ReturnsBasicRenderer() + { + var ctx = new RenderContext(); + var loc = new LocalizationManager(Locale.EN); + var renderer = RendererFactory.Create(ctx, loc); + Assert.IsType(renderer); + } + + [Theory] + [InlineData(UIFeature.TextColors)] + [InlineData(UIFeature.ExtendedColors)] + [InlineData(UIFeature.ArrowKeySelection)] + [InlineData(UIFeature.InventoryPanel)] + [InlineData(UIFeature.ResourcePanel)] + [InlineData(UIFeature.StatsPanel)] + [InlineData(UIFeature.PortraitPanel)] + [InlineData(UIFeature.ChatPanel)] + [InlineData(UIFeature.FullLayout)] + [InlineData(UIFeature.KeyboardShortcuts)] + [InlineData(UIFeature.BoxAnimation)] + [InlineData(UIFeature.CraftingPanel)] + public void Create_WithSpectreFeature_ReturnsSpectreRenderer(UIFeature feature) + { + var ctx = new RenderContext(); + ctx.Unlock(feature); + var loc = new LocalizationManager(Locale.EN); + var renderer = RendererFactory.Create(ctx, loc); + Assert.IsType(renderer); + } + + [Fact] + public void Create_CompletionTrackerOnly_ReturnsBasicRenderer() + { + // CompletionTracker alone doesn't require Spectre features + var ctx = new RenderContext(); + ctx.Unlock(UIFeature.CompletionTracker); + var loc = new LocalizationManager(Locale.EN); + var renderer = RendererFactory.Create(ctx, loc); + Assert.IsType(renderer); + } +} + +// ── SpectreRenderer Output Tests ──────────────────────────────────────── + +[Collection("ConsoleTests")] +public class SpectreRendererOutputTests : IDisposable +{ + private readonly ConsoleCapture _capture = new(); + private readonly LocalizationManager _loc = new(Locale.EN); + + public void Dispose() => _capture.Dispose(); + + private SpectreRenderer CreateRenderer(params UIFeature[] features) + { + var ctx = new RenderContext(); + foreach (var f in features) ctx.Unlock(f); + return new SpectreRenderer(ctx, _loc); + } + + // ── ShowMessage ── + + [Fact] + public void ShowMessage_NoFeatures_DoesNotThrow() + { + var r = CreateRenderer(); + r.ShowMessage("Hello world"); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowMessage_HasColors_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowMessage("Hello world"); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowMessage_WithBrackets_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowMessage("This has [red] brackets [/] in it"); + Assert.NotEmpty(_capture.Output); + } + + // ── ShowError ── + + [Fact] + public void ShowError_NoFeatures_DoesNotThrow() + { + var r = CreateRenderer(); + r.ShowError("Something went wrong"); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowError_HasColors_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowError("Something went wrong"); + Assert.NotEmpty(_capture.Output); + } + + // ── ShowBoxOpening (no animation) ── + + [Fact] + public void ShowBoxOpening_NoFeatures_DoesNotThrow() + { + var r = CreateRenderer(); + r.ShowBoxOpening("Test Box", "Common"); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowBoxOpening_HasColors_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowBoxOpening("Cool Box", "Rare"); + Assert.NotEmpty(_capture.Output); + } + + [Theory] + [InlineData("Common")] + [InlineData("Uncommon")] + [InlineData("Rare")] + [InlineData("Epic")] + [InlineData("Legendary")] + [InlineData("Mythic")] + [InlineData("Unknown")] + public void ShowBoxOpening_AllRarities_DoesNotThrow(string rarity) + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowBoxOpening("Box", rarity); + Assert.NotEmpty(_capture.Output); + } + + // ── ShowLootReveal ── + + private static List<(string, string, string)> SampleLoot() => + [ + ("Health Potion", "Common", "Consumable"), + ("Epic Sword", "Epic", "Equipment"), + ("Box of Boxes", "Uncommon", "Box") + ]; + + [Fact] + public void ShowLootReveal_NoFeatures_DoesNotThrow() + { + var r = CreateRenderer(); + r.ShowLootReveal(SampleLoot()); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowLootReveal_HasColors_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowLootReveal(SampleLoot()); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowLootReveal_HasInventoryPanel_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors, UIFeature.InventoryPanel); + r.ShowLootReveal(SampleLoot()); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowLootReveal_EmptyList_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors, UIFeature.InventoryPanel); + r.ShowLootReveal([]); + // Empty is OK, no crash + } + + [Fact] + public void ShowLootReveal_ItemNameWithBrackets_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowLootReveal([("[Special] Item", "Rare", "Box")]); + Assert.NotEmpty(_capture.Output); + } + + // ── ShowAdventureDialogue ── + + [Fact] + public void ShowAdventureDialogue_WithCharacter_NoFeatures_DoesNotThrow() + { + var r = CreateRenderer(); + r.ShowAdventureDialogue("Guard", "Halt!"); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowAdventureDialogue_WithoutCharacter_NoFeatures_DoesNotThrow() + { + var r = CreateRenderer(); + r.ShowAdventureDialogue(null, "The wind howls."); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowAdventureDialogue_WithCharacter_HasColors_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowAdventureDialogue("Dragon", "Prepare yourself!"); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowAdventureDialogue_WithoutCharacter_HasColors_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowAdventureDialogue(null, "A mysterious voice echoes."); + Assert.NotEmpty(_capture.Output); + } + + // ── ShowUIFeatureUnlocked ── + + [Fact] + public void ShowUIFeatureUnlocked_NoFeatures_DoesNotThrow() + { + var r = CreateRenderer(); + r.ShowUIFeatureUnlocked("Text Colors"); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowUIFeatureUnlocked_HasColors_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowUIFeatureUnlocked("Inventory Panel"); + Assert.NotEmpty(_capture.Output); + } + + // ── ShowInteraction ── + + [Fact] + public void ShowInteraction_NoFeatures_DoesNotThrow() + { + var r = CreateRenderer(); + r.ShowInteraction("You found something interesting."); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowInteraction_HasColors_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.ShowInteraction("A secret passage opens."); + Assert.NotEmpty(_capture.Output); + } + + // ── ShowGameState ── + + [Fact] + public void ShowGameState_NoFeatures_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + var ctx = new RenderContext(); + var r = new SpectreRenderer(ctx, _loc); + r.ShowGameState(state, ctx); + // No panels → minimal or no output, but no crash + } + + [Fact] + public void ShowGameState_SequentialPanels_DoesNotThrow() + { + var state = CreateFullState(); + var ctx = new RenderContext(); + ctx.Unlock(UIFeature.TextColors); + ctx.Unlock(UIFeature.PortraitPanel); + ctx.Unlock(UIFeature.StatsPanel); + ctx.Unlock(UIFeature.ResourcePanel); + ctx.Unlock(UIFeature.InventoryPanel); + ctx.Unlock(UIFeature.ChatPanel); + ctx.Unlock(UIFeature.CompletionTracker); + ctx.CompletionPercent = 42; + var r = new SpectreRenderer(ctx, _loc); + r.ShowGameState(state, ctx); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowGameState_FullLayout_DoesNotThrow() + { + var state = CreateFullState(); + var ctx = new RenderContext(); + foreach (var f in Enum.GetValues()) + ctx.Unlock(f); + ctx.CompletionPercent = 88; + var r = new SpectreRenderer(ctx, _loc); + r.ShowGameState(state, ctx); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowGameState_FullLayout_PartialPanels_DoesNotThrow() + { + var state = GameState.Create("Test", Locale.EN); + var ctx = new RenderContext(); + ctx.Unlock(UIFeature.TextColors); + ctx.Unlock(UIFeature.FullLayout); + // FullLayout unlocked but individual panels are NOT → shows placeholder "???" + var r = new SpectreRenderer(ctx, _loc); + r.ShowGameState(state, ctx); + Assert.NotEmpty(_capture.Output); + } + + // ── Clear ── + + [Fact] + public void Clear_DoesNotThrow() + { + var r = CreateRenderer(UIFeature.TextColors); + r.Clear(); // May throw IOException internally, should be caught + } + + // ── Helpers ── + + private static GameState CreateFullState() + { + var state = GameState.Create("Test", Locale.EN); + state.Appearance = new PlayerAppearance + { + HairStyle = HairStyle.Short, + EyeStyle = EyeStyle.Blue, + BodyStyle = BodyStyle.RegularTShirt, + LegStyle = LegStyle.Short, + ArmStyle = ArmStyle.Regular + }; + state.AddItem(ItemInstance.Create("health_potion_small")); + state.AddItem(ItemInstance.Create("box_of_boxes")); + state.VisibleResources.Add(ResourceType.Health); + state.Resources[ResourceType.Health] = new ResourceState(75, 100); + state.VisibleStats.Add(StatType.Strength); + state.Stats[StatType.Strength] = 15; + state.TotalBoxesOpened = 42; + return state; + } +} + +// ── BasicRenderer Output Tests ────────────────────────────────────────── + +[Collection("ConsoleTests")] +public class BasicRendererOutputTests : IDisposable +{ + private readonly ConsoleCapture _capture = new(); + private readonly LocalizationManager _loc = new(Locale.EN); + private readonly BasicRenderer _renderer; + + public BasicRendererOutputTests() + { + _renderer = new BasicRenderer(_loc); + } + + public void Dispose() => _capture.Dispose(); + + [Fact] + public void ShowMessage_WritesToConsole() + { + _renderer.ShowMessage("Test message"); + Assert.Contains("Test message", _capture.Output); + } + + [Fact] + public void ShowError_WritesWithPrefix() + { + _renderer.ShowError("Bad thing"); + Assert.Contains("ERROR", _capture.Output); + } + + [Fact] + public void ShowBoxOpening_DoesNotThrow() + { + _renderer.ShowBoxOpening("Test Box", "Rare"); + Assert.NotEmpty(_capture.Output); + } + + [Fact] + public void ShowLootReveal_WritesItems() + { + _renderer.ShowLootReveal([("Sword", "Epic", "Equipment")]); + Assert.Contains("Sword", _capture.Output); + } + + [Fact] + public void ShowLootReveal_EmptyList_DoesNotThrow() + { + _renderer.ShowLootReveal([]); + // No items → just the header line + } + + [Fact] + public void ShowGameState_WithCompletionTracker_ShowsPercent() + { + var state = GameState.Create("Test", Locale.EN); + var ctx = new RenderContext(); + ctx.Unlock(UIFeature.CompletionTracker); + ctx.CompletionPercent = 55; + _renderer.ShowGameState(state, ctx); + Assert.Contains("55", _capture.Output); + } + + [Fact] + public void ShowGameState_WithoutCompletionTracker_NoOutput() + { + var state = GameState.Create("Test", Locale.EN); + var ctx = new RenderContext(); + _renderer.ShowGameState(state, ctx); + Assert.Empty(_capture.Output); + } + + [Fact] + public void ShowAdventureDialogue_WithCharacter_DoesNotThrow() + { + _renderer.ShowAdventureDialogue("NPC", "Hello!"); + Assert.Contains("NPC", _capture.Output); + } + + [Fact] + public void ShowAdventureDialogue_WithoutCharacter_DoesNotThrow() + { + _renderer.ShowAdventureDialogue(null, "Narration text."); + Assert.Contains("Narration", _capture.Output); + } + + [Fact] + public void ShowUIFeatureUnlocked_DoesNotThrow() + { + _renderer.ShowUIFeatureUnlocked("Text Colors"); + Assert.Contains("Text Colors", _capture.Output); + } + + [Fact] + public void ShowInteraction_DoesNotThrow() + { + _renderer.ShowInteraction("Something happens"); + Assert.Contains("Something happens", _capture.Output); + } + + [Fact] + public void Clear_DoesNotThrow() + { + _renderer.Clear(); + } +} + +// ── Input Method Tests ────────────────────────────────────────────────── + +[Collection("ConsoleTests")] +public class RendererInputTests : IDisposable +{ + private readonly ConsoleCapture _capture = new(); + private readonly LocalizationManager _loc = new(Locale.EN); + private readonly TextReader _origIn; + + public RendererInputTests() + { + _origIn = Console.In; + } + + public void Dispose() + { + Console.SetIn(_origIn); + _capture.Dispose(); + } + + [Fact] + public void BasicRenderer_ShowSelection_ReturnsCorrectIndex() + { + Console.SetIn(new StringReader("2\n")); + var r = new BasicRenderer(_loc); + int result = r.ShowSelection("Choose:", ["A", "B", "C"]); + Assert.Equal(1, result); + } + + [Fact] + public void BasicRenderer_ShowTextInput_ReturnsInput() + { + Console.SetIn(new StringReader("Hello\n")); + var r = new BasicRenderer(_loc); + string result = r.ShowTextInput("Name"); + Assert.Equal("Hello", result); + } + + [Fact] + public void BasicRenderer_ShowAdventureChoice_ReturnsIndex() + { + Console.SetIn(new StringReader("1\n")); + var r = new BasicRenderer(_loc); + int result = r.ShowAdventureChoice(["Fight", "Run"]); + Assert.Equal(0, result); + } + + [Fact] + public void SpectreRenderer_ShowSelection_NoArrow_ReturnsIndex() + { + Console.SetIn(new StringReader("3\n")); + var ctx = new RenderContext(); + // No arrow selection → falls through to number input path + var r = new SpectreRenderer(ctx, _loc); + int result = r.ShowSelection("Pick:", ["X", "Y", "Z"]); + Assert.Equal(2, result); + } + + [Fact] + public void SpectreRenderer_ShowSelection_HasColorsNoArrow_ReturnsIndex() + { + Console.SetIn(new StringReader("1\n")); + var ctx = new RenderContext(); + ctx.Unlock(UIFeature.TextColors); + var r = new SpectreRenderer(ctx, _loc); + int result = r.ShowSelection("Pick:", ["Only"]); + Assert.Equal(0, result); + } +} diff --git a/tests/OpenTheBox.Tests/UnitTest1.cs b/tests/OpenTheBox.Tests/UnitTest1.cs index e7f3361..ad9e192 100644 --- a/tests/OpenTheBox.Tests/UnitTest1.cs +++ b/tests/OpenTheBox.Tests/UnitTest1.cs @@ -382,6 +382,62 @@ public class ContentValidationTests 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] @@ -416,6 +472,300 @@ public class ContentValidationTests Assert.True(File.Exists(path), $"Missing French translation: {path}"); } + // ── Full run integration tests ───────────────────────────────────── + + [Fact] + public void FullRun_AllReachableContentIsObtained() + { + // Simulates an entire game playthrough by repeatedly opening boxes + // until all content reachable via box openings is unlocked. + // Uses only the simulation (zero I/O) to prove game completability. + + var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); + var simulation = new GameSimulation(registry, new Random(42)); + var state = GameState.Create("CompletionTest", Locale.EN); + + // ── Compute the "reachable set" dynamically from item definitions ── + + var allItems = registry.Items.Values.ToList(); + + // All MetaUnlock values that exist on items → expected UI features + var expectedUIFeatures = allItems + .Where(i => i.MetaUnlock.HasValue) + .Select(i => i.MetaUnlock!.Value) + .ToHashSet(); + + // All cosmetic definition IDs → expected cosmetics + var expectedCosmetics = allItems + .Where(i => i.CosmeticSlot.HasValue) + .Select(i => i.Id) + .ToHashSet(); + + // All AdventureTheme values that exist on items → expected adventures + var expectedAdventures = allItems + .Where(i => i.AdventureTheme.HasValue) + .Select(i => i.AdventureTheme!.Value) + .ToHashSet(); + + // All ResourceType values that exist on items → expected visible resources + var expectedResources = allItems + .Where(i => i.ResourceType.HasValue) + .Select(i => i.ResourceType!.Value) + .ToHashSet(); + + // All lore fragment definition IDs + var expectedLore = allItems + .Where(i => i.Category == ItemCategory.LoreFragment) + .Select(i => i.Id) + .ToHashSet(); + + // All StatType values that exist on items → expected visible stats + var expectedStats = allItems + .Where(i => i.StatType.HasValue) + .Select(i => i.StatType!.Value) + .ToHashSet(); + + // All FontStyle values that exist on items → expected fonts + var expectedFonts = allItems + .Where(i => i.FontStyle.HasValue) + .Select(i => i.FontStyle!.Value) + .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; // Game loop broke — will be caught by asserts + + var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; + var events = simulation.ProcessAction(action, state); + + // Track all received items + foreach (var evt in events.OfType()) + { + seenDefinitionIds.Add(evt.Item.DefinitionId); + } + + totalBoxesOpened++; + + // Check if we've covered everything + 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); + + if (allUIFeatures && allCosmetics && allAdventures && allResources && allLore && allStats && allFonts) + 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)}"); + } + + [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); + 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."); + } + + [Fact] + public void FullRun_PacingReport() + { + // Diagnostic test: outputs a pacing report showing when each piece + // of content is first unlocked. Not a pass/fail test — it always + // passes but prints progression milestones to the test output. + + var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath); + var simulation = new GameSimulation(registry, new Random(42)); + 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(); + + 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; + + 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); + + 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)); + + if (curUI > prevUI) + milestones.Add((totalBoxesOpened, $"UI Feature {curUI}/{expectedUIFeatures.Count}: +{string.Join(", ", state.UnlockedUIFeatures.Where(f => expectedUIFeatures.Contains(f)).Except(milestones.Where(m => m.description.StartsWith("UI")).SelectMany(_ => Array.Empty())))}")); + 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; + + complete = curUI == expectedUIFeatures.Count + && curCos == expectedCosmetics.Count + && curAdv == expectedAdventures.Count + && curRes == expectedResources.Count + && curLore == expectedLore.Count; + } + + // Build the pacing report + var report = new System.Text.StringBuilder(); + report.AppendLine(); + report.AppendLine("╔══════════════════════════════════════════════════════╗"); + report.AppendLine("║ PACING REPORT (seed=42) ║"); + report.AppendLine("╠══════════════════════════════════════════════════════╣"); + report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-32}║"); + report.AppendLine($"║ Game completed: {(complete ? "YES" : "NO"),-36}║"); + report.AppendLine("╠══════════════════════════════════════════════════════╣"); + report.AppendLine("║ Box# │ Milestone ║"); + report.AppendLine("╟────────┼────────────────────────────────────────────╢"); + foreach (var (boxNum, desc) in milestones) + { + report.AppendLine($"║ {boxNum,5} │ {desc,-42} ║"); + } + 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; + + report.AppendLine($"║ UI Features ({expectedUIFeatures.Count,2}) complete at box #{uiDoneAt,-17}║"); + report.AppendLine($"║ Cosmetics ({expectedCosmetics.Count,2}) complete at box #{cosDoneAt,-17}║"); + report.AppendLine($"║ Adventures ({expectedAdventures.Count,2}) complete at box #{advDoneAt,-17}║"); + report.AppendLine($"║ Resources ({expectedResources.Count,2}) complete at box #{resDoneAt,-17}║"); + report.AppendLine($"║ Lore ({expectedLore.Count,2}) complete at box #{loreDoneAt,-17}║"); + report.AppendLine("╚══════════════════════════════════════════════════════╝"); + + // Output via ITestOutputHelper would be ideal, but Assert message works too + Assert.True(true, report.ToString()); + + // Also write to console for visibility in test runners + Console.WriteLine(report.ToString()); + } + // ── Helpers ────────────────────────────────────────────────────────── private static List LoadItems()