using OpenTheBox.Core; using OpenTheBox.Core.Crafting; using OpenTheBox.Core.Enums; using OpenTheBox.Core.Items; using OpenTheBox.Data; using OpenTheBox.Localization; using OpenTheBox.Persistence; using OpenTheBox.Rendering; using OpenTheBox.Simulation; using OpenTheBox.Simulation.Actions; using OpenTheBox.Simulation.Events; using OpenTheBox.Adventures; namespace OpenTheBox; public static class Program { private static GameState _state = null!; private static ContentRegistry _registry = null!; private static LocalizationManager _loc = null!; private static SaveManager _saveManager = null!; private static GameSimulation _simulation = null!; private static RenderContext _renderContext = null!; private static IRenderer _renderer = null!; private static CraftingEngine _craftingEngine = 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) { try { _saveManager = new SaveManager(); _loc = new LocalizationManager(Locale.EN); _renderContext = new RenderContext(); _renderer = RendererFactory.Create(_renderContext, _loc, _registry); 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() { // Check for existing saves to determine startup flow var existingSaves = _saveManager.ListSlots(); if (existingSaves.Count > 0) { // Saves exist: load locale from the most recent save, skip language prompt var mostRecent = existingSaves[0]; // Already sorted by SavedAt descending var recentState = _saveManager.Load(mostRecent.Name); if (recentState != null) { _loc.Change(recentState.CurrentLocale); _renderer = RendererFactory.Create(_renderContext, _loc, _registry); } } else { // No saves: prompt language first _renderer.Clear(); var langOptions = new List { "English", "Français" }; int langChoice = _renderer.ShowSelection("Language / Langue", langOptions); var selectedLocale = langChoice == 0 ? Locale.EN : Locale.FR; _loc.Change(selectedLocale); _renderer = RendererFactory.Create(_renderContext, _loc, _registry); } while (_running) { _renderer.Clear(); _renderer.ShowMessage("========================================"); _renderer.ShowMessage(" OPEN THE BOX"); _renderer.ShowMessage("========================================"); _renderer.ShowMessage(""); _renderer.ShowMessage(_loc.Get("game.subtitle")); _renderer.ShowMessage(""); // Rebuild saves list (may have changed after new game / save) existingSaves = _saveManager.ListSlots(); var options = new List(); var actions = new List(); // If saves exist, show "Continuer" as first option with most recent save info if (existingSaves.Count > 0) { var recent = existingSaves[0]; var savedAt = recent.SavedAt.ToLocalTime(); options.Add($"{_loc.Get("menu.continue")} ({recent.Name} {savedAt:dd/MM/yyyy HH:mm})"); actions.Add("continue"); } options.Add(_loc.Get("menu.new_game")); actions.Add("new_game"); if (existingSaves.Count > 1) { options.Add(_loc.Get("menu.load_game")); actions.Add("load_game"); } options.Add(_loc.Get("menu.language")); actions.Add("language"); options.Add(_loc.Get("menu.quit")); actions.Add("quit"); int choice = _renderer.ShowSelection("", options); switch (actions[choice]) { case "continue": await ContinueGame(existingSaves[0].Name); break; case "new_game": await NewGame(); break; case "load_game": await LoadGame(); break; case "language": ChangeLanguage(); break; case "quit": _running = false; break; } } } private static async Task ContinueGame(string slotName) { var loaded = _saveManager.Load(slotName); if (loaded == null) { _renderer.ShowError("Failed to load save."); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); return; } _state = loaded; _loc.Change(_state.CurrentLocale); InitializeGame(); _renderer.ShowMessage(_loc.Get("misc.welcome_back", _state.PlayerName)); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); await GameLoop(); } private static async Task NewGame() { string name = _renderer.ShowTextInput(_loc.Get("prompt.name")); if (string.IsNullOrWhiteSpace(name)) name = "BoxOpener"; _state = GameState.Create(name, _loc.CurrentLocale); InitializeGame(); var starterBox = ItemInstance.Create("box_starter"); _state.AddItem(starterBox); _renderer.ShowMessage(""); _renderer.ShowMessage($"Welcome, {name}!"); _renderer.ShowMessage(_loc.Get("box.starter.desc")); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); await GameLoop(); } private static async Task LoadGame() { var slots = _saveManager.ListSlots(); if (slots.Count == 0) { _renderer.ShowMessage(_loc.Get("save.no_saves")); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); return; } var options = slots.Select(s => $"{s.Name} ({s.SavedAt:yyyy-MM-dd HH:mm})").ToList(); options.Add(_loc.Get("menu.back")); int choice = _renderer.ShowSelection(_loc.Get("save.choose_slot"), options); if (choice >= slots.Count) return; var loaded = _saveManager.Load(slots[choice].Name); if (loaded == null) { _renderer.ShowError("Failed to load save."); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); return; } _state = loaded; _loc.Change(_state.CurrentLocale); InitializeGame(); _renderer.ShowMessage(_loc.Get("misc.welcome_back", _state.PlayerName)); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); await GameLoop(); } private static void InitializeGame() { _registry = ContentRegistry.LoadFromFiles( "content/data/items.json", "content/data/boxes.json", "content/data/interactions.json", "content/data/recipes.json" ); _simulation = new GameSimulation(_registry); _craftingEngine = new CraftingEngine(); _renderContext = RenderContext.FromGameState(_state); _renderer = RendererFactory.Create(_renderContext, _loc, _registry); } private static void ChangeLanguage() { var options = new List { "English", "Francais" }; int choice = _renderer.ShowSelection(_loc.Get("menu.language"), options); var newLocale = choice == 0 ? Locale.EN : Locale.FR; _loc.Change(newLocale); if (_state != null) _state.CurrentLocale = newLocale; _renderer = RendererFactory.Create(_renderContext, _loc, _registry); } private static async Task GameLoop() { while (_running) { // Auto-save when returning to the hub (if the feature is unlocked) if (_state.HasUIFeature(UIFeature.AutoSave)) { _saveManager.Save(_state, _state.PlayerName); } // Tick crafting jobs (InProgress → Completed) TickCraftingJobs(); _renderer.Clear(); UpdateCompletionPercent(); _renderer.ShowGameState(_state, _renderContext); var actions = BuildActionList(); if (actions.Count == 0) { _renderer.ShowMessage(_loc.Get("error.no_boxes")); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); break; } int choice = _renderer.ShowSelection( _loc.Get("prompt.choose_action"), actions.Select(a => a.label).ToList()); await ExecuteAction(actions[choice].action); } } private static List<(string label, string action)> BuildActionList() { var actions = new List<(string label, string action)>(); var boxes = _state.Inventory.Where(i => _registry.IsBox(i.DefinitionId)).ToList(); if (boxes.Count > 0) actions.Add((_loc.Get("action.open_box") + $" ({boxes.Count})", "open_box")); if (_state.Inventory.Count > 0) actions.Add((_loc.Get("action.inventory"), "inventory")); if (_state.UnlockedAdventures.Count > 0) actions.Add((_loc.Get("action.adventure"), "adventure")); if (_state.UnlockedCosmetics.Count > 0) actions.Add((_loc.Get("action.appearance"), "appearance")); var completedJobs = _state.ActiveCraftingJobs.Where(j => j.IsComplete).ToList(); if (completedJobs.Count > 0) actions.Add((_loc.Get("action.collect_crafting") + $" ({completedJobs.Count})", "collect_crafting")); if (!_state.HasUIFeature(UIFeature.AutoSave)) actions.Add((_loc.Get("action.save"), "save")); actions.Add((_loc.Get("action.quit"), "quit")); return actions; } private static async Task ExecuteAction(string action) { switch (action) { case "open_box": await OpenBoxAction(); break; case "inventory": ShowInventory(); break; case "adventure": await StartAdventure(); break; case "appearance": ChangeAppearance(); break; case "collect_crafting": await CollectCrafting(); break; case "save": SaveGame(); break; case "quit": _running = false; break; } } private static async Task OpenBoxAction() { var boxes = _state.Inventory.Where(i => _registry.IsBox(i.DefinitionId)).ToList(); if (boxes.Count == 0) { _renderer.ShowMessage(_loc.Get("box.no_boxes")); return; } // Group boxes by type so the list isn't bloated with duplicates var grouped = boxes.GroupBy(b => b.DefinitionId).ToList(); var boxNames = grouped.Select(g => { var name = GetLocalizedName(g.Key); return g.Count() > 1 ? $"{name} (x{g.Count()})" : name; }).ToList(); boxNames.Add(_loc.Get("menu.back")); int choice = _renderer.ShowSelection(_loc.Get("prompt.choose_box"), boxNames); if (choice >= grouped.Count) return; var boxInstance = grouped[choice].First(); var openAction = new OpenBoxAction(boxInstance.Id) { BoxDefinitionId = boxInstance.DefinitionId }; var events = _simulation.ProcessAction(openAction, _state); await RenderEvents(events); } private static async Task RenderEvents(List events) { // Collect IDs of items that are auto-consumed (auto-opening boxes) // so we don't show "You received" for items the player never actually gets var autoConsumedIds = events.OfType().Select(e => e.InstanceId).ToHashSet(); // Collect all received items to show as a single grouped loot reveal var allLoot = new List<(string name, string rarity, string category)>(); // Show only the primary box opening (not auto-opened intermediaries) bool primaryBoxShown = false; foreach (var evt in events) { switch (evt) { case BoxOpenedEvent boxEvt: var boxDef = _registry.GetBox(boxEvt.BoxId); var boxName = _loc.Get(boxDef?.NameKey ?? boxEvt.BoxId); if (!boxEvt.IsAutoOpen && !primaryBoxShown) { _renderer.ShowBoxOpening(boxName, boxDef?.Rarity.ToString() ?? "Common"); primaryBoxShown = true; } // Auto-opened boxes are silent — their loot appears in the grouped reveal break; case ItemReceivedEvent itemEvt: // Skip loot reveal for auto-consumed items (auto-opening boxes) if (autoConsumedIds.Contains(itemEvt.Item.Id)) break; var itemDef = _registry.GetItem(itemEvt.Item.DefinitionId); var itemBoxDef = itemDef is null ? _registry.GetBox(itemEvt.Item.DefinitionId) : null; allLoot.Add(( GetLocalizedName(itemEvt.Item.DefinitionId), (itemDef?.Rarity ?? itemBoxDef?.Rarity ?? ItemRarity.Common).ToString(), (itemDef?.Category ?? ItemCategory.Box).ToString() )); break; case UIFeatureUnlockedEvent uiEvt: _renderContext.Unlock(uiEvt.Feature); _renderer = RendererFactory.Create(_renderContext, _loc, _registry); _renderer.ShowUIFeatureUnlocked( _loc.Get(GetUIFeatureLocKey(uiEvt.Feature))); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); break; case InteractionTriggeredEvent interEvt: _renderer.ShowInteraction(_loc.Get(interEvt.DescriptionKey)); break; case ResourceChangedEvent resEvt: var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); _renderer.ShowMessage($"{resName}: {resEvt.OldValue} -> {resEvt.NewValue}"); break; case MessageEvent msgEvt: _renderer.ShowMessage(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); break; case ChoiceRequiredEvent choiceEvt: _renderer.ShowSelection(_loc.Get(choiceEvt.Prompt), choiceEvt.Options); break; case LootTableModifiedEvent: _renderer.ShowMessage(_loc.Get("interaction.key_no_match")); break; case AdventureStartedEvent advEvt: await RunAdventure(advEvt.Theme); break; case MusicPlayedEvent: _renderer.ShowMessage(_loc.Get("box.music.desc")); if (OperatingSystem.IsWindows()) PlayMelody(); break; case CookieFortuneEvent cookieEvt: _renderer.ShowMessage("--- Fortune Cookie ---"); _renderer.ShowMessage(_loc.Get(cookieEvt.MessageKey)); _renderer.ShowMessage("----------------------"); break; case CraftingStartedEvent craftEvt: var recipeName = _registry.Recipes.TryGetValue(craftEvt.RecipeId, out var recDef) ? _loc.Get(recDef.NameKey) : craftEvt.RecipeId; _renderer.ShowMessage(_loc.Get("craft.started", recipeName, craftEvt.Workstation.ToString())); break; case CraftingCompletedEvent craftDoneEvt: _renderer.ShowMessage(_loc.Get("craft.completed", craftDoneEvt.Workstation.ToString())); break; case CraftingCollectedEvent: // Item reveals are already handled by individual ItemReceivedEvents break; } } // Show all received loot as a single grouped reveal if (allLoot.Count > 0) { _renderer.ShowLootReveal(allLoot); } _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); } private static void ShowInventory() { _renderer.Clear(); if (_state.Inventory.Count == 0) { _renderer.ShowMessage("Your inventory is empty. Open more boxes!"); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); return; } var grouped = _state.Inventory .GroupBy(i => i.DefinitionId) .Select(g => { var def = _registry.GetItem(g.Key); var bDef = def is null ? _registry.GetBox(g.Key) : null; var baseName = GetLocalizedName(g.Key); return ( name: g.Count() > 1 ? $"{baseName} (x{g.Count()})" : baseName, rarity: (def?.Rarity ?? bDef?.Rarity ?? ItemRarity.Common).ToString(), category: (def?.Category ?? ItemCategory.Box).ToString() ); }) .OrderBy(i => i.category) .ThenBy(i => i.name) .ToList(); _renderer.ShowLootReveal(grouped); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); } private static async Task StartAdventure() { var available = _state.UnlockedAdventures.ToList(); if (available.Count == 0) { _renderer.ShowMessage("No adventures available yet. Keep opening boxes!"); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); return; } var options = available.Select(a => { bool completed = _state.CompletedAdventures.Contains(a.ToString()); return (completed ? "[Done] " : "") + a.ToString(); }).ToList(); options.Add(_loc.Get("menu.back")); int choice = _renderer.ShowSelection(_loc.Get("action.adventure"), options); if (choice >= available.Count) return; await RunAdventure(available[choice]); } private static async Task RunAdventure(AdventureTheme theme) { try { var adventureEngine = new AdventureEngine(_renderer, _loc); var events = await adventureEngine.PlayAdventure(theme, _state); foreach (var evt in events) { if (evt.Kind == GameEventKind.ItemGranted) _state.AddItem(ItemInstance.Create(evt.TargetId, evt.Amount)); } _renderer.ShowMessage(_loc.Get("adventure.completed")); } catch (FileNotFoundException) { _renderer.ShowMessage($"Adventure '{theme}' is coming soon! The boxes are still being assembled."); } catch (Exception ex) { _renderer.ShowError($"Adventure error: {ex.Message}"); } _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); } private static void ChangeAppearance() { var cosmeticItems = _state.Inventory .Where(i => { var def = _registry.GetItem(i.DefinitionId); return def?.Category == ItemCategory.Cosmetic && def.CosmeticSlot.HasValue; }) .ToList(); if (cosmeticItems.Count == 0) { _renderer.ShowMessage("No cosmetics available yet. Open Style Boxes!"); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); return; } var options = cosmeticItems.Select(i => { var def = _registry.GetItem(i.DefinitionId); return $"[{def?.CosmeticSlot}] {GetLocalizedName(i.DefinitionId)}"; }).ToList(); options.Add(_loc.Get("menu.back")); int choice = _renderer.ShowSelection(_loc.Get("action.appearance"), options); if (choice >= cosmeticItems.Count) return; var action = new EquipCosmeticAction(cosmeticItems[choice].Id); var events = _simulation.ProcessAction(action, _state); foreach (var evt in events) { if (evt is CosmeticEquippedEvent cosEvt) _renderer.ShowMessage($"Equipped {cosEvt.Slot}: {cosEvt.NewValue}"); else if (evt is MessageEvent msgEvt) _renderer.ShowMessage(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); } _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); } private static void TickCraftingJobs() { if (_craftingEngine is null) return; var events = _craftingEngine.TickJobs(_state); // Completed jobs will be shown in the CraftingPanel; no blocking render here. } private static async Task CollectCrafting() { var events = _craftingEngine.CollectCompleted(_state, _registry); // Run meta pass on newly crafted items (some results may unlock features) var newItems = events.OfType().Select(e => e.Item).ToList(); var metaEngine = new MetaEngine(); events.AddRange(metaEngine.ProcessNewItems(newItems, _state, _registry)); // Cascade: collected results may be ingredients for other recipes events.AddRange(_craftingEngine.AutoCraftCheck(_state, _registry)); await RenderEvents(events); } private static void SaveGame() { _renderer.ShowMessage(_loc.Get("save.saving")); _saveManager.Save(_state, _state.PlayerName); _renderer.ShowMessage(_loc.Get("save.saved", _state.PlayerName)); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); } /// /// 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", UIFeature.AutoSave => "meta.autosave", _ => $"meta.{feature.ToString().ToLower()}" }; private static string GetLocalizedName(string definitionId) { var itemDef = _registry.GetItem(definitionId); if (itemDef is not null) return _loc.Get(itemDef.NameKey); var boxDef = _registry.GetBox(definitionId); if (boxDef is not null) return _loc.Get(boxDef.NameKey); return _loc.Get(definitionId); } [System.Runtime.Versioning.SupportedOSPlatform("windows")] private static void PlayMelody() { // Simple melodies using Console.Beep (Windows only) var melodies = new (int freq, int dur)[][] { // "Box Opening Fanfare" [(523, 150), (587, 150), (659, 150), (784, 300), (659, 150), (784, 400)], // "Loot Drop Jingle" [(440, 200), (554, 200), (659, 200), (880, 400)], // "Mystery Theme" [(330, 300), (294, 300), (330, 300), (392, 300), (330, 600)], }; try { var melody = melodies[Random.Shared.Next(melodies.Length)]; foreach (var (freq, dur) in melody) { Console.Beep(freq, dur); } } catch { // Console.Beep not supported on all platforms } } }