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.Rendering.Panels; using OpenTheBox.Simulation; using Spectre.Console; using OpenTheBox.Simulation.Actions; using OpenTheBox.Simulation.Events; using OpenTheBox.Adventures; using System.Reflection; 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 _appRunning = true; private static bool _gameRunning; private static DateTime _sessionStart; private static readonly string LogFilePath = Path.Combine( AppContext.BaseDirectory, "openthebox-error.log"); public static async Task Main(string[] args) { // --snapshot N: directly load snapshot_N save and start playing int snapshotSlot = 0; var snapshotIdx = Array.IndexOf(args, "--snapshot"); if (snapshotIdx >= 0 && snapshotIdx + 1 < args.Length && int.TryParse(args[snapshotIdx + 1], out int sn) && sn >= 1 && sn <= 9) snapshotSlot = sn; try { _saveManager = new SaveManager(); _loc = new LocalizationManager(Locale.EN); _renderContext = new RenderContext(); RefreshRenderer(); if (snapshotSlot > 0) await LoadSnapshot(snapshotSlot); else 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); } } /// /// Returns the version string from the assembly's InformationalVersion attribute, /// which includes the git commit hash embedded at build time. /// private static string GetVersionString() { var info = typeof(Program).Assembly .GetCustomAttribute(); return info?.InformationalVersion ?? "dev"; } 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 } } /// /// Creates or refreshes the renderer based on current context. /// private static void RefreshRenderer() { _renderer = RendererFactory.Create(_renderContext, _loc, _registry); } 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); RefreshRenderer(); } } 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); RefreshRenderer(); } while (_appRunning) { _renderer.Clear(); _renderer.ShowMessage("========================================"); _renderer.ShowMessage(" OPEN THE BOX"); _renderer.ShowMessage($" {GetVersionString()}"); _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": _appRunning = 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(); ShowAdaptiveWelcome(); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); await GameLoop(); } private static void ShowAdaptiveWelcome() { int boxes = _state.TotalBoxesOpened; string name = _state.PlayerName; string message = boxes switch { >= 500 => _loc.Get("misc.welcome_back_500", name, boxes.ToString()), >= 200 => _loc.Get("misc.welcome_back_200", name, boxes.ToString()), >= 50 => _loc.Get("misc.welcome_back_50", name), _ => _loc.Get("misc.welcome_back", name) }; _renderer.ShowMessage(message); } 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(_loc.Get("misc.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(); ShowAdaptiveWelcome(); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); await GameLoop(); } /// /// Loads a snapshot save (snapshot_1 through snapshot_9) and enters the game loop directly. /// Used with --snapshot N for quick visual testing of different progression stages. /// private static async Task LoadSnapshot(int slot) { string slotName = $"snapshot_{slot}"; var loaded = _saveManager.Load(slotName); if (loaded == null) { _renderer = RendererFactory.Create(_renderContext, _loc, _registry); _renderer.ShowError($"Snapshot save '{slotName}' not found. Run: dotnet test --filter GenerateSaveSnapshots"); _renderer.WaitForKeyPress("Press any key to exit..."); return; } _state = loaded; _loc.Change(_state.CurrentLocale); InitializeGame(); _renderer.ShowMessage($"Loaded snapshot {slot}: {_state.TotalBoxesOpened} boxes opened"); _renderer.ShowMessage($"UI features: {_state.UnlockedUIFeatures.Count}, " + $"Adventures: {_state.UnlockedAdventures.Count}, " + $"Cosmetics: {_state.UnlockedCosmetics.Count}, " + $"Workshops: {_state.UnlockedWorkstations.Count}"); _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); RefreshRenderer(); } 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; RefreshRenderer(); } private static async Task GameLoop() { _sessionStart = DateTime.UtcNow; _gameRunning = true; while (_gameRunning) { // Update play time from this session _state.TotalPlayTime += DateTime.UtcNow - _sessionStart; _sessionStart = DateTime.UtcNow; // 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(); // Proposal 7B: Only clear screen when FullLayout is active; // before that, let text scroll like a classic terminal if (_renderContext.HasFullLayout) _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": _gameRunning = 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); RefreshRenderer(); var featureLabel = _loc.Get(GetUIFeatureLocKey(uiEvt.Feature)); _renderer.ShowUIFeatureUnlocked(featureLabel); AddEventLog($"★ {featureLabel}"); _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}"); AddEventLog($"{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 AdventureUnlockedEvent advUnlockedEvt: var advName = GetAdventureName(advUnlockedEvt.Theme); _renderer.ShowMessage(_loc.Get("adventure.unlocked", advName)); AddEventLog($"🗺 {advName}"); 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); // Proposal 6A: Feed loot to the event log foreach (var (name, rarity, _) in allLoot) AddEventLog($"+ {name} [{_loc.Get($"rarity.{rarity.ToLower()}")}]"); // Proposal 4: Show inline resource summary when ResourcePanel is not unlocked if (!_renderContext.HasResourcePanel && _state.Resources.Count > 0) { var resSummary = string.Join(" | ", _state.Resources .Where(r => _state.VisibleResources.Contains(r.Key)) .Select(r => $"{_loc.Get($"resource.{r.Key.ToString().ToLower()}")} {r.Value.Current}/{r.Value.Max}")); if (resSummary.Length > 0) _renderer.ShowMessage($" [{resSummary}]"); } } _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); } private static void ShowInventory() { if (_state.Inventory.Count == 0) { _renderer.ShowMessage(_loc.Get("inventory.empty")); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); return; } // Before InventoryPanel is unlocked, show a raw text list if (!_renderContext.HasInventoryPanel) { ShowRawInventory(); return; } var grouped = InventoryPanel.GetGroupedItems(_state, _registry); int totalItems = grouped.Count; int maxVisible = InventoryPanel.MaxVisibleRows; int maxOffset = Math.Max(0, totalItems - maxVisible); int scrollOffset = 0; int selectedIndex = 0; int previousRenderedLines = 0; _renderer.Clear(); while (true) { // Recalculate grouped items (may change after using a consumable) grouped = InventoryPanel.GetGroupedItems(_state, _registry); totalItems = grouped.Count; if (totalItems == 0) return; // inventory emptied maxOffset = Math.Max(0, totalItems - maxVisible); // Clamp selection & scroll selectedIndex = Math.Clamp(selectedIndex, 0, totalItems - 1); scrollOffset = Math.Clamp(scrollOffset, 0, maxOffset); // Auto-scroll to keep selection visible if (selectedIndex < scrollOffset) scrollOffset = selectedIndex; else if (selectedIndex >= scrollOffset + maxVisible) scrollOffset = selectedIndex - maxVisible + 1; // Render to buffer to avoid flicker var writer = new StringWriter(); var bufferConsole = AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(writer), Ansi = AnsiSupport.Detect, ColorSystem = ColorSystemSupport.Detect }); bufferConsole.Profile.Width = SpectreRenderer.RefWidth; bufferConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex)); // Detail panel for selected item var selectedGroup = grouped[selectedIndex]; var detailPanel = InventoryPanel.RenderDetailPanel(selectedGroup, _registry, _loc, _state); if (detailPanel is not null) bufferConsole.Write(detailPanel); // Controls hint if (_renderContext.HasArrowSelection) { bool isUsable = selectedGroup.Category is ItemCategory.Consumable && selectedGroup.Def?.ResourceType is not null; bool isLore = selectedGroup.Category is ItemCategory.LoreFragment; string controls = isUsable ? _loc.Get("inventory.controls_use") : isLore ? _loc.Get("inventory.controls_lore") : _loc.Get("inventory.controls"); bufferConsole.MarkupLine($"[dim]{Markup.Escape(controls)}[/]"); } string rendered = writer.ToString(); int renderedLines = rendered.Split('\n').Length; // Move cursor to top of previous render and overwrite if (previousRenderedLines > 0) { Console.Write($"\x1b[{previousRenderedLines}A\x1b[J"); } Console.Write(rendered); previousRenderedLines = renderedLines; var key = Console.ReadKey(intercept: true); switch (key.Key) { case ConsoleKey.UpArrow: selectedIndex = Math.Max(0, selectedIndex - 1); break; case ConsoleKey.DownArrow: selectedIndex = Math.Min(totalItems - 1, selectedIndex + 1); break; case ConsoleKey.PageUp: selectedIndex = Math.Max(0, selectedIndex - maxVisible); break; case ConsoleKey.PageDown: selectedIndex = Math.Min(totalItems - 1, selectedIndex + maxVisible); break; case ConsoleKey.Enter: HandleInventoryAction(selectedGroup); break; case ConsoleKey.Escape: case ConsoleKey.Q: return; } } } /// /// Shows a minimal raw-text inventory before the InventoryPanel feature is unlocked. /// private static void ShowRawInventory() { _renderer.ShowMessage($"--- {_loc.Get("ui.inventory")} ---"); var groups = _state.Inventory .GroupBy(i => i.DefinitionId) .ToList(); foreach (var g in groups) { string name = GetLocalizedName(g.Key); int qty = g.Sum(i => i.Quantity); _renderer.ShowMessage(qty > 1 ? $" {name} (x{qty})" : $" {name}"); } _renderer.ShowMessage(""); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); } private static void HandleInventoryAction(InventoryGroup item) { if (item.Def is null) return; switch (item.Category) { case ItemCategory.Consumable when item.Def.ResourceType.HasValue: // Use the consumable through the simulation var events = _simulation.ProcessAction( new UseItemAction(item.FirstInstance.Id), _state); foreach (var evt in events) { switch (evt) { case ResourceChangedEvent resEvt: var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); string itemName = _loc.Get(item.Def.NameKey); int remaining = _state.Inventory.Where(i => i.DefinitionId == item.DefId).Sum(i => i.Quantity); string usedMsg = remaining > 0 ? _loc.Get("inventory.item_used_qty", itemName, remaining.ToString()) : _loc.Get("inventory.item_used", itemName); _renderer.ShowMessage(usedMsg); _renderer.ShowMessage($"{resName}: {resEvt.OldValue} → {resEvt.NewValue}"); AddEventLog($"🧪 {itemName} → {resName} {resEvt.OldValue}→{resEvt.NewValue}"); break; case MessageEvent msgEvt: _renderer.ShowMessage(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); break; } } // No WaitForKeyPress — return to inventory immediately for rapid consumption break; case ItemCategory.Cookie: // Use the cookie through the simulation (similar to Consumable) var cookieEvents = _simulation.ProcessAction( new UseItemAction(item.FirstInstance.Id), _state); foreach (var evt in cookieEvents) { switch (evt) { case CookieFortuneEvent cookieEvt: _renderer.ShowMessage("--- Fortune Cookie ---"); _renderer.ShowMessage(_loc.Get(cookieEvt.MessageKey)); _renderer.ShowMessage("----------------------"); break; case ResourceChangedEvent resEvt: var cookieResName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); _renderer.ShowMessage($"{cookieResName}: {resEvt.OldValue} → {resEvt.NewValue}"); break; case MessageEvent msgEvt: _renderer.ShowMessage(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); break; } } break; case ItemCategory.LoreFragment: // Display the full lore text in a dedicated panel ShowLoreFragment(item); break; } } private static void ShowLoreFragment(InventoryGroup item) { _renderer.Clear(); string name = _loc.Get(item.Def!.NameKey); string loreKey = $"lore.fragment_{item.DefId.Replace("lore_", "")}"; string loreText = _loc.Get(loreKey); var panel = new Panel($"[italic]{Markup.Escape(loreText)}[/]") .Header($"[bold yellow]📜 {Markup.Escape(name)}[/]") .Border(BoxBorder.Double) .BorderStyle(new Style(Color.Yellow)) .Padding(2, 1) .Expand(); AnsiConsole.Write(panel); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); } private static async Task StartAdventure() { var available = _state.UnlockedAdventures.ToList(); if (available.Count == 0) { _renderer.ShowMessage(_loc.Get("adventure.none_available")); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); return; } var options = available.Select(a => { bool completed = _state.CompletedAdventures.Contains(a.ToString()); string prefix = completed ? $"[{_loc.Get("adventure.done")}] " : ""; return prefix + GetAdventureName(a); }).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")); // Destiny is the final adventure — offer an epilogue choice if (theme == AdventureTheme.Destiny) { _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); _renderer.ShowMessage(_loc.Get("destiny.epilogue")); var endOptions = new List { _loc.Get("destiny.continue"), _loc.Get("destiny.quit") }; int endChoice = _renderer.ShowSelection("", endOptions); if (endChoice == 1) { _renderer.ShowMessage(_loc.Get("destiny.thanks")); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); _gameRunning = false; return; } } } catch (FileNotFoundException) { _renderer.ShowMessage(_loc.Get("adventure.coming_soon", GetAdventureName(theme))); } catch (Exception ex) { _renderer.ShowError($"Adventure error: {ex.Message}"); } _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); } private static void ChangeAppearance() { // Deduplicate cosmetics by definition ID (inventory may have multiple instances) var cosmeticItems = _state.Inventory .Where(i => { var def = _registry.GetItem(i.DefinitionId); return def?.Category == ItemCategory.Cosmetic && def.CosmeticSlot.HasValue; }) .GroupBy(i => i.DefinitionId) .Select(g => g.First()) .ToList(); if (cosmeticItems.Count == 0) { _renderer.ShowMessage(_loc.Get("cosmetic.no_cosmetics")); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); return; } var options = cosmeticItems.Select(i => { var def = _registry.GetItem(i.DefinitionId); var slotKey = $"cosmetic.slot.{def?.CosmeticSlot?.ToString().ToLower()}"; var slotName = _loc.Get(slotKey); return $"[{slotName}] {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) { var slotKey = $"cosmetic.slot.{cosEvt.Slot.ToString().ToLower()}"; var slotName = _loc.Get(slotKey); // Look up the cosmetic item definition to get its nameKey var cosmeticDef = _registry.Items.Values.FirstOrDefault( d => d.CosmeticSlot == cosEvt.Slot && string.Equals(d.CosmeticValue, cosEvt.NewValue, StringComparison.OrdinalIgnoreCase)); var valueName = cosmeticDef is not null ? _loc.Get(cosmeticDef.NameKey) : cosEvt.NewValue; _renderer.ShowMessage(_loc.Get("cosmetic.equipped", slotName, valueName)); } 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 total = totalCosmetics + totalAdventures + totalUIFeatures + totalResources + totalStats; var unlocked = _state.UnlockedCosmetics.Count + _state.UnlockedAdventures.Count + _state.UnlockedUIFeatures.Count + _state.VisibleResources.Count + _state.VisibleStats.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 GetAdventureName(AdventureTheme theme) { string key = $"adventure.name.{theme}"; var name = _loc.Get(key); // Fallback to enum name if no localization key exists return name.StartsWith("[MISSING:") ? theme.ToString() : name; } private const int MaxEventLogEntries = 20; /// /// Adds a message to the in-memory event log displayed in the ChatPanel. /// private static void AddEventLog(string message) { _state.EventLog.Add(message); if (_state.EventLog.Count > MaxEventLogEntries) _state.EventLog.RemoveAt(0); } 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 } } }