openthebox/src/OpenTheBox/Program.cs

712 lines
26 KiB
C#
Raw Normal View History

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()
{
2026-03-11 18:33:10 +01:00
// 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<string> { "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("");
2026-03-11 18:33:10 +01:00
// 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)
{
2026-03-11 18:33:10 +01:00
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);
2026-03-11 18:33:10 +01:00
switch (actions[choice])
{
2026-03-11 18:33:10 +01:00
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;
}
}
}
2026-03-11 18:33:10 +01:00
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<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;
_renderer = RendererFactory.Create(_renderContext, _loc, _registry);
}
private static async Task GameLoop()
{
while (_running)
{
2026-03-11 18:33:10 +01:00
// 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"));
2026-03-11 18:33:10 +01:00
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<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();
2026-03-11 18:33:10 +01:00
// 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);
2026-03-11 18:33:10 +01:00
if (!boxEvt.IsAutoOpen && !primaryBoxShown)
{
_renderer.ShowBoxOpening(boxName, boxDef?.Rarity.ToString() ?? "Common");
2026-03-11 18:33:10 +01:00
primaryBoxShown = true;
}
2026-03-11 18:33:10 +01:00
// 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;
2026-03-11 18:33:10 +01:00
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;
}
}
2026-03-11 18:33:10 +01:00
// 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<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 totalFonts = Enum.GetValues<FontStyle>().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",
2026-03-11 18:33:10 +01:00
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
}
}
}