- BoxEngine: treat meta box chain items as "already obtained" when the entire meta unlock sequence is complete, preventing useless box_meta_mastery drops that only contain more boxes. - Add progression hints under completion tracker showing lore fragment count, adventure completion count, and a teaser for the final adventure.
1105 lines
42 KiB
C#
1105 lines
42 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)
|
|
{
|
|
string adventureLabel = _loc.Get("action.adventure");
|
|
if (_state.CompletedAdventures.Count == 0)
|
|
adventureLabel = $"({_loc.Get("action.new")}) {adventureLabel}";
|
|
actions.Add((adventureLabel, "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 messages to show after loot reveal
|
|
var deferredMessages = 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);
|
|
_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
|
|
// Build message with both trigger and partner item names
|
|
string interMsg;
|
|
if (interEvt.TriggerItemId is not null && interEvt.PartnerItemId is not null)
|
|
{
|
|
var triggerName = GetLocalizedName(interEvt.TriggerItemId);
|
|
var partnerName = GetLocalizedName(interEvt.PartnerItemId);
|
|
// First show which items react, then show the rule-specific message
|
|
var chainIndicator = interEvt.ChainStep > 0
|
|
? new string(UnicodeSupport.IsUtf8 ? '⚡' : '!', interEvt.ChainStep) + " "
|
|
: "";
|
|
var reactionMsg = _loc.Get("interaction.chain_reaction", triggerName, partnerName);
|
|
var detailMsg = _loc.Get(interEvt.DescriptionKey, triggerName, partnerName);
|
|
interMsg = $"{chainIndicator}{reactionMsg}\n {detailMsg}";
|
|
}
|
|
else if (interEvt.TriggerItemId is not null)
|
|
{
|
|
interMsg = _loc.Get(interEvt.DescriptionKey, GetLocalizedName(interEvt.TriggerItemId));
|
|
}
|
|
else
|
|
{
|
|
interMsg = _loc.Get(interEvt.DescriptionKey);
|
|
}
|
|
deferredMessages.Add(interMsg);
|
|
break;
|
|
|
|
case ChainBonusEvent chainEvt:
|
|
var bonusName = GetLocalizedName(chainEvt.BonusItemId);
|
|
deferredMessages.Add(_loc.Get("interaction.chain_bonus", chainEvt.ChainLength) + $" {bonusName}");
|
|
break;
|
|
|
|
case ResourceChangedEvent resEvt:
|
|
var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}");
|
|
_renderer.ShowMessage($"{resName}: {resEvt.OldValue} {UnicodeSupport.Arrow} {resEvt.NewValue}");
|
|
break;
|
|
|
|
case MessageEvent msgEvt:
|
|
deferredMessages.Add(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? []));
|
|
break;
|
|
|
|
case ChoiceRequiredEvent choiceEvt:
|
|
_renderer.ShowSelection(_loc.Get(choiceEvt.Prompt), choiceEvt.Options);
|
|
break;
|
|
|
|
case LootTableModifiedEvent:
|
|
deferredMessages.Add(_loc.Get("interaction.key_no_match"));
|
|
break;
|
|
|
|
case AdventureUnlockedEvent advUnlockedEvt:
|
|
var advName = GetAdventureName(advUnlockedEvt.Theme);
|
|
deferredMessages.Add(_loc.Get("adventure.unlocked", 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, _loc.Get($"workstation.{craftEvt.Workstation}")));
|
|
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);
|
|
|
|
// Resource summary removed — characteristics are shown in the dedicated panel
|
|
}
|
|
|
|
// Show deferred messages after the loot reveal
|
|
foreach (var msg in deferredMessages)
|
|
{
|
|
_renderer.ShowMessage("");
|
|
_renderer.ShowInteraction(msg);
|
|
}
|
|
|
|
_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}");
|
|
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;
|
|
|
|
// Build next-step hints
|
|
var hints = new List<string>();
|
|
|
|
// Lore fragments
|
|
var totalLore = _registry.Items.Values.Count(i => i.Category == ItemCategory.LoreFragment);
|
|
var ownedLore = _state.Inventory.Count(i =>
|
|
_registry.GetItem(i.DefinitionId)?.Category == ItemCategory.LoreFragment);
|
|
if (ownedLore < totalLore)
|
|
hints.Add(_loc.Get("hint.lore", ownedLore, totalLore));
|
|
|
|
// Adventures completed
|
|
var completedAdv = _state.CompletedAdventures.Count;
|
|
if (completedAdv < totalAdventures)
|
|
hints.Add(_loc.Get("hint.adventures", completedAdv, totalAdventures));
|
|
|
|
// Destiny adventure not yet unlocked — show condition
|
|
if (!_state.UnlockedAdventures.Contains(AdventureTheme.Destiny))
|
|
hints.Add(_loc.Get("hint.destiny"));
|
|
|
|
_renderContext.NextHints = hints;
|
|
}
|
|
|
|
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.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 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
|
|
}
|
|
}
|
|
}
|