openthebox/src/OpenTheBox/Program.cs
Samuel Bouchet 240989e0ff Add UTF-8 detection with ASCII fallback for terminal compatibility
- Detect Windows Terminal (WT_SESSION), PowerShell, and non-Windows
  terminals to enable UTF-8 output encoding automatically
- Use ★ and → when UTF-8 is supported, fall back to * and -> on cmd.exe
- Set Console.OutputEncoding = UTF8 at startup for capable terminals
2026-03-14 20:46:33 +01:00

1081 lines
41 KiB
C#

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)
{
UnicodeSupport.Initialize();
// --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);
}
}
/// <summary>
/// Returns the version string from the assembly's InformationalVersion attribute,
/// which includes the git commit hash embedded at build time.
/// </summary>
private static string GetVersionString()
{
var info = typeof(Program).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
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
}
}
/// <summary>
/// Creates or refreshes the renderer based on current context.
/// </summary>
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<string> { "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<string>();
var actions = new List<string>();
// 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();
}
/// <summary>
/// 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.
/// </summary>
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<string> { "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<GameEvent> 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<ItemConsumedEvent>().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)>();
// Collect interactions to show after loot reveal with context
var deferredInteractions = new List<string>();
// Track consumed item names for interaction context
var consumedItemNames = new Dictionary<Guid, string>();
// 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 ItemConsumedEvent consumedEvt:
// Track consumed item names for interaction context
consumedItemNames[consumedEvt.InstanceId] = GetLocalizedName(
_state.Inventory.FirstOrDefault(i => i.Id == consumedEvt.InstanceId)?.DefinitionId
?? events.OfType<ItemReceivedEvent>()
.FirstOrDefault(r => r.Item.Id == consumedEvt.InstanceId)?.Item.DefinitionId
?? "?");
break;
case InteractionTriggeredEvent interEvt:
// Defer interaction display until after loot reveal
deferredInteractions.Add(_loc.Get(interEvt.DescriptionKey));
break;
case ResourceChangedEvent resEvt:
var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}");
_renderer.ShowMessage($"{resName}: {resEvt.OldValue} {UnicodeSupport.Arrow} {resEvt.NewValue}");
AddEventLog($"{resName}: {resEvt.OldValue} {UnicodeSupport.Arrow} {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}]");
}
}
// Show deferred interactions after the loot reveal, with context
foreach (var interactionMsg in deferredInteractions)
{
_renderer.ShowMessage("");
_renderer.ShowInteraction(interactionMsg);
}
_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;
}
}
}
/// <summary>
/// Shows a minimal raw-text inventory before the InventoryPanel feature is unlocked.
/// </summary>
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} {UnicodeSupport.Arrow} {resEvt.NewValue}");
AddEventLog($"{itemName} {UnicodeSupport.Arrow} {resName} {resEvt.OldValue}{UnicodeSupport.Arrow}{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} {UnicodeSupport.Arrow} {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<string>
{
_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<ItemReceivedEvent>().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"));
}
/// <summary>
/// Resolves the localized display name for any definition ID (item or box).
/// </summary>
private static void UpdateCompletionPercent()
{
if (!_renderContext.HasCompletionTracker) return;
var totalCosmetics = _registry.Items.Values.Count(i => i.CosmeticSlot.HasValue);
var totalAdventures = Enum.GetValues<AdventureTheme>().Length;
var totalUIFeatures = Enum.GetValues<UIFeature>().Length;
var totalResources = Enum.GetValues<ResourceType>().Length;
var totalStats = Enum.GetValues<StatType>().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;
/// <summary>
/// Adds a message to the in-memory event log displayed in the ChatPanel.
/// </summary>
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
}
}
}