diff --git a/CLAUDE.md b/CLAUDE.md index 7e1bbb1..8601ad2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,9 @@ See [specifications.md](specifications.md) for detailed content organization. ## Build & Run ``` dotnet build -dotnet run --project src/OpenTheBox +dotnet run --project src/OpenTheBox # Classic Spectre.Console mode +dotnet run --project src/OpenTheBox -- --tui # Terminal.Gui panel layout +dotnet run --project src/OpenTheBox -- --snapshot 5 # Load snapshot save #5 ``` ## Test @@ -61,6 +63,25 @@ Key things to look for: Weights are in `content/data/boxes.json`. The main generator is `box_of_boxes` (auto-opens, produces the next box). Adjust weights there and in tier boxes (`box_not_great`, `box_ok_tier`, etc.) to tune pacing. +## Save Snapshots (Visual Testing) +Generate save files at 9 progression stages for quick visual testing: +``` +dotnet test --filter "GenerateSaveSnapshots" --logger "console;verbosity=detailed" +``` +This creates `saves/snapshot_1.otb` through `saves/snapshot_9.otb` (from 10 boxes to 2000 boxes opened). + +Load a snapshot directly: +``` +dotnet run --project src/OpenTheBox -- --snapshot 3 +``` +Where 1-9 corresponds to: (1) very early, (2) first unlocks, (3) several panels, (4) adventures, (5) crafting, (6) most features, (7) near completion, (8) endgame, (9) post-endgame. + +## Terminal.Gui Mode +Run with `--tui` for a tmux-like panel layout using Terminal.Gui: +``` +dotnet run --project src/OpenTheBox -- --tui +``` + ## Conventions - C# 12 with file-scoped namespaces, primary constructors where appropriate - Immutable records for value types, sealed classes for services diff --git a/README.md b/README.md index 9dfde3e..416ea18 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,7 @@ dotnet run --project src/OpenTheBox ## Distribute ```powershell -.\publish.ps1 # Builds win-x64 by default -.\publish.ps1 -Runtime win-arm64 # Or target another platform +dotnet publish -c Release -r win-x64 ``` This produces a self-contained single-file executable in `publish//`. The target machine does **not** need .NET installed. Distribute the entire folder (exe + `content/`). diff --git a/bugs.md b/bugs.md index 7cec7c7..57ed279 100644 --- a/bugs.md +++ b/bugs.md @@ -4,116 +4,44 @@ Les sujets dans FIXME doivent être corrigé, puis déplacé dans "DONE", puis c # FIXME -## direct number -La fonctionnalité "navigation avec les flèche" devrait laisser la possibilité de continuer d'utiliser les numéros du claviers (pavé alpha ou alphanumérique) pour sélectionner les choix correspondant avec un seul bouton - -## panneau d'inventaire impraticable - -``` -┌─Resources─────────────────┐ -│ No resources visible yet. │ -└───────────────────────────┘ -┌─Inventory───────────────────────────────────────────────┐ -│ Inventory │ -│ ┌───────────────────────────┬──────────┬────────┬─────┐ │ -│ │ Name │ Category │ Rarity │ Qty │ │ -│ ├───────────────────────────┼──────────┼────────┼─────┤ │ -│ │ blood_vial │ - │ - │ 1 │ │ -│ │ box_epic │ - │ - │ 1 │ │ -│ │ box_music │ - │ - │ 2 │ │ -│ │ box_not_great │ - │ - │ 1 │ │ -│ │ box_ok_tier │ - │ - │ 3 │ │ -│ │ contemporary_phone │ - │ - │ 1 │ │ -│ │ contemporary_usb │ - │ - │ 1 │ │ -│ │ cosmetic_arms_regular │ - │ - │ 1 │ │ -│ │ cosmetic_body_robotic │ - │ - │ 1 │ │ -│ │ cosmetic_eyes_blue │ - │ - │ 1 │ │ -│ │ cosmetic_eyes_brown │ - │ - │ 1 │ │ -│ │ cosmetic_hair_cyberpunk │ - │ - │ 1 │ │ -│ │ cosmetic_hair_short │ - │ - │ 1 │ │ -│ │ cosmetic_legs_rocketboots │ - │ - │ 1 │ │ -│ │ food_ration │ - │ - │ 2 │ │ -│ │ gold_pouch │ - │ - │ 1 │ │ -│ │ health_potion_medium │ - │ - │ 1 │ │ -│ │ health_potion_small │ - │ - │ 4 │ │ -│ │ lore_10 │ - │ - │ 1 │ │ -│ │ lore_2 │ - │ - │ 2 │ │ -│ │ lore_3 │ - │ - │ 1 │ │ -│ │ lore_5 │ - │ - │ 2 │ │ -│ │ lore_6 │ - │ - │ 1 │ │ -│ │ lore_9 │ - │ - │ 1 │ │ -│ │ mana_crystal_small │ - │ - │ 3 │ │ -│ │ material_bronze_ingot │ - │ - │ 1 │ │ -│ │ material_bronze_raw │ - │ - │ 2 │ │ -│ │ material_carbonfiber_raw │ - │ - │ 1 │ │ -│ │ material_iron_raw │ - │ - │ 3 │ │ -│ │ medieval_scroll │ - │ - │ 1 │ │ -│ │ meta_animation │ - │ - │ 1 │ │ -│ │ meta_arrows │ - │ - │ 1 │ │ -│ │ meta_autosave │ - │ - │ 1 │ │ -│ │ meta_colors │ - │ - │ 1 │ │ -│ │ meta_inventory │ - │ - │ 1 │ │ -│ │ meta_resources │ - │ - │ 1 │ │ -│ │ music_melody │ - │ - │ 1 │ │ -│ │ resource_max_food │ - │ - │ 1 │ │ -│ │ resource_max_health │ - │ - │ 1 │ │ -│ │ stamina_drink │ - │ - │ 1 │ │ -│ │ tint_cyan │ - │ - │ 4 │ │ -│ │ tint_orange │ - │ - │ 3 │ │ -│ └───────────────────────────┴──────────┴────────┴─────┘ │ -└─────────────────────────────────────────────────────────┘ -Que veux-tu faire ? - -> Ouvrir une boîte (7) - Voir l'inventaire - Partir à l'aventure - Changer d'apparence - Retourner au menu -``` - -la liste d'action se retrouve collée tout en bas, on ne voit plus les stats qui dépassent en haut, les objets dans le tableau ne sont pas traduits, n'ont pas de catégorie, ni de rareté. -Dans "voir l'inventaire" ça fonctionne mais le tableau est trop grand, la casse "name" est gigantesque car certains noms sont trop longs et on perds l'association avec la rareté. - -Attendu: homogénéiser les rendus -Attendu: la colonne name a une taille raisonnable, les noms trops longs défilent (comme des en allez-retours) si pas trop complexe -Attendu: la hauteur est limitée et les flèches Pg up et Pg down permettent de scroll. Un indicateur indique que ces touches sont dispos. -Attendu: le panneau inventaire est positionné à côté des stats (pour éviter de le cacher) en + d'être limité en hauteur -Attendu: le rendu complet à tout moment doit passer dans 50 lignes de hauteur. - -## raccourcis claviers - -j'ai débloqué la meta interface raccourcis clavier mais ça ne change rien… -Par ailleurs les raccourcis claviers (direct numbers évoqués plus haut) devraient être de base pour des raisons d'accessibilité. - -## meta - interface - -les meta interface sont toujours obtenues dans des meta - les bases. Ce sera mieux de ne plus looter de meta base mais directement des meta interface. -idem base => interface => personnalisation. Donner directement la bonne boite. - -## bug cosmétique - -erreur survenue lorsque j'ai essayé d'ouvrir l'interface "change d'apparence" après avoir débloqué une box cosmétique - -[2026-03-11 22:14:10] InvalidOperationException: Could not find color or style 'Cheveux'. - at Spectre.Console.StyleParser.Parse(String text) in /_/src/Spectre.Console/StyleParser.cs:line 10 - at Spectre.Console.MarkupParser.Parse(String text, Style style) in /_/src/Spectre.Console/Internal/Text/Markup/MarkupParser.cs:line 29 - at Spectre.Console.SelectionPrompt`1.Spectre.Console.IListPromptStrategy.Render(IAnsiConsole console, Boolean scrollable, Int32 cursorIndex, IEnumerable`1 items, Boolean skipUnselectableItems, String searchText) in /_/src/Spectre.Console/Prompts/SelectionPrompt.cs:line 167 - at Spectre.Console.ListPrompt`1.BuildRenderable(ListPromptState`1 state) in /_/src/Spectre.Console/Prompts/List/ListPrompt.cs:line 89 - at Spectre.Console.ListPromptRenderHook`1.Process(RenderOptions options, IEnumerable`1 renderables)+MoveNext() - at Spectre.Console.AnsiBuilder.Build(IAnsiConsole console, IRenderable renderable) in /_/src/Spectre.Console/Internal/Backends/Ansi/AnsiBuilder.cs:line 17 - at Spectre.Console.AnsiConsoleBackend.Write(IRenderable renderable) in /_/src/Spectre.Console/Internal/Backends/Ansi/AnsiConsoleBackend.cs:line 30 - at Spectre.Console.CursorExtensions.Hide(IAnsiConsoleCursor cursor) in /_/src/Spectre.Console/Extensions/CursorExtensions.cs:line 33 - at Spectre.Console.ListPrompt`1.Show(ListPromptTree`1 tree, Func`2 converter, SelectionMode selectionMode, Boolean skipUnselectableItems, Boolean searchEnabled, Int32 requestedPageSize, Boolean wrapAround, CancellationToken cancellationToken) in /_/src/Spectre.Console/Prompts/List/ListPrompt.cs:line 55 - at Spectre.Console.SelectionPrompt`1.ShowAsync(IAnsiConsole console, CancellationToken cancellationToken) - at OpenTheBox.Rendering.SpectreRenderer.ShowSelection(String prompt, List`1 options) in D:\projets\openthebox\src\OpenTheBox\Rendering\SpectreRenderer.cs:line 123 - at OpenTheBox.Program.ChangeAppearance() in D:\projets\openthebox\src\OpenTheBox\Program.cs:line 590 - at OpenTheBox.Program.ExecuteAction(String action) in D:\projets\openthebox\src\OpenTheBox\Program.cs:line 327 - at OpenTheBox.Program.GameLoop() in D:\projets\openthebox\src\OpenTheBox\Program.cs:line 287 - at OpenTheBox.Program.NewGame() in D:\projets\openthebox\src\OpenTheBox\Program.cs:line 193 - at OpenTheBox.Program.MainMenuLoop() in D:\projets\openthebox\src\OpenTheBox\Program.cs:line 142 - at OpenTheBox.Program.Main(String[] args) in D:\projets\openthebox\src\OpenTheBox\Program.cs:line 40 - -=> Ajouter un test après le correctif pour attraper les cas similaires. # DONE + +## Terminal size 120×30 — DONE +SpectreRenderer cap désormais `AnsiConsole.Profile.Width` à 120 colonnes (RefWidth). Le layout est conçu pour tenir dans 30 lignes. Les constantes `RefWidth=120` et `RefHeight=30` servent de référence. + +## Layout compact tmux — DONE +RenderFullLayout utilise des `Table.NoBorder()` pour placer les panels côte à côte sans gaps. Row 1: Portrait(20) | Stats(30) | Resources(fill). Row 2: Inventory(60) | Crafting+Chat+Completion(fill). RenderSequentialPanels groupe aussi les panels top côte à côte quand plusieurs sont débloqués. + +## Couleurs sur le portrait — DONE +Chaque type de cosmétique a maintenant sa propre couleur intrinsèque (yeux bleus=dodgerblue, cheveux feu=red, cyberpunk=aqua, etc.). Les tints explicites (HairTint/BodyTint) restent prioritaires. Les yeux, jambes et bras ont aussi leurs propres couleurs au lieu d'être tous blancs. + + + +## panneau d'inventaire — DONE +Scroll interactif implémenté : ↑↓ ligne par ligne, PgUp/PgDn page par page, Esc/Q pour sortir. Panneau limité à 15 lignes avec indicateur de position (ex: 1-15/42). Noms traduits, catégories, raretés colorées, colonne Name fixée à 24 chars. + +## Polices de caractère — supprimées +Impossible de changer la police du terminal programmatiquement. Les items font ont été supprimés du jeu (items.json, boxes.json, enum, code). + +## Portrait représente une boîte — DONE +Le portrait ASCII art représente maintenant une boîte (+------+) avec des cosmétiques dessus : cheveux sur le dessus, yeux sur la face, corps comme décoration, jambes en dessous, bras sur les côtés. + +## Police de caractère — DONE +Les fonts sont des collectibles purs (comptent pour la complétion). Le terminal gère sa propre police. Un message explicite est maintenant affiché au loot : « Police 'X' collectionnée ! (Collectible — la police de votre terminal reste inchangée) ». + +## Double crochets aventures — DONE +Le préfixe [Terminée] utilisait [[...]] (échappement Spectre) mais ShowSelection échappe déjà les options. Corrigé en utilisant des crochets simples. + +## Boîtes meta auto-upgrade — DONE +BoxEngine détecte automatiquement quand tous les items d'un tier meta sont obtenus et upgrade le box_meta vers le tier suivant : basics → interface → deep → resources → mastery. Revert de la fusion incorrecte. + +## Double boîte d'aventure pirate (interactions) — DONE +Le prompt ChoiceRequiredEvent utilisait un texte anglais hardcodé comme clé de localisation → [MISSING:...]. Corrigé : utilise la clé "prompt.choose_interaction" et affiche les DescriptionKey des règles (traduits) au lieu des IDs bruts. + +## Aventure destiny FR — DONE +Créé intro.fr.lor pour l'aventure destiny (68 tags traduits). Tests ajoutés (existence, parsing, couverture tags). + +## Fin de partie après destiny — DONE +Après l'aventure destiny, un choix épilogue est proposé : continuer en jeu libre ou refermer la boîte (quitter). Le ton reste poétique et cohérent avec le récit. diff --git a/content/adventures/destiny/intro.fr.lor b/content/adventures/destiny/intro.fr.lor new file mode 100644 index 0000000..17ce4bf --- /dev/null +++ b/content/adventures/destiny/intro.fr.lor @@ -0,0 +1,248 @@ +// Destiny Adventure - Open The Box +// French Translation + +#intro-silence // "Silence. Then, a creak. The sound of something very old deciding to speak." +Silence. Puis, un craquement. Le son de quelque chose de très ancien qui décide de parler. + +#intro-lastbox // "So. You've come to the last box." +Alors. Tu es arrivé à la dernière boîte. + +#intro-wondered // "I wondered when you'd find me. They all do, eventually. The ones who keep opening." +Je me demandais quand tu me trouverais. Ils y arrivent tous, tôt ou tard. Ceux qui continuent d'ouvrir. + +#intro-opens // "I am not a box you open. I am a box that opens you." +Je ne suis pas une boîte qu'on ouvre. Je suis une boîte qui t'ouvre, toi. + +#intro-sit // "Sit down. Let me show you what I've seen." +Assieds-toi. Laisse-moi te montrer ce que j'ai vu. + +#gallery-intro // "Every box you opened left an echo. A little ripple in the cardboard cosmos." +Chaque boîte que tu as ouverte a laissé un écho. Une petite ondulation dans le cosmos de carton. + +#gallery-collect // "I collected them. I collect everything. It's sort of my thing." +Je les ai collectés. Je collectionne tout. C'est un peu ma raison d'être. + +#gallery-show // "Let me show you the Gallery of Echoes. Your echoes." +Laisse-moi te montrer la Galerie des Échos. Tes échos. + +#echo-space // "The stars flicker. A faint hum fills the room -- the sound of a ship you once commanded." +Les étoiles scintillent. Un bourdonnement lointain emplit la pièce — le son d'un vaisseau que tu as commandé autrefois. + +#echo-space-words // "You sailed through the void and opened a box that defied physics. Captain Nova would be proud." +Tu as navigué à travers le vide et ouvert une boîte qui défiait la physique. Le Capitaine Nova serait fier. + +#echo-medieval // "A banner unfurls from nowhere, bearing a crest you almost remember." +Une bannière se déploie de nulle part, arborant un blason dont tu te souviens presque. + +#echo-medieval-words // "Knights and dragons and a kingdom built on cardboard. You wore the crown well." +Des chevaliers et des dragons et un royaume bâti sur du carton. Tu portais bien la couronne. + +#echo-pirate // "The smell of salt and old wood drifts past. A shanty plays, very faintly." +Une odeur de sel et de vieux bois passe en flottant. Un chant de marin joue, très faiblement. + +#echo-pirate-words // "You sailed the seas of chance. Every treasure chest is just a box with ambition." +Tu as sillonné les mers du hasard. Chaque coffre au trésor n'est qu'une boîte avec de l'ambition. + +#echo-contemporary // "A phone notification chimes. Then another. Then silence." +Une notification de téléphone sonne. Puis une autre. Puis le silence. + +#echo-contemporary-words // "The modern world, where every package is a promise. You understood that." +Le monde moderne, où chaque colis est une promesse. Tu as compris ça. + +#echo-sentimental // "Rain taps against a window that isn't there. The scent of old paper and popsicle sticks." +La pluie tapote contre une fenêtre qui n'existe pas. L'odeur du vieux papier et des bâtons de glace. + +#echo-sentimental-words // "You opened a box of memories and let them breathe again. That took courage." +Tu as ouvert une boîte de souvenirs et tu les as laissés respirer à nouveau. Ça demandait du courage. + +#echo-prehistoric // "The ground rumbles. Something ancient stirs in the echo." +Le sol gronde. Quelque chose d'ancien remue dans l'écho. + +#echo-prehistoric-words // "Before language, before tools, there were boxes. You were there at the beginning." +Avant le langage, avant les outils, il y avait des boîtes. Tu étais là au commencement. + +#echo-cosmic // "The walls dissolve into starfields. Galaxies spiral in the space between heartbeats." +Les murs se dissolvent en champs d'étoiles. Des galaxies spiralent dans l'espace entre deux battements de cœur. + +#echo-cosmic-words // "You touched the infinite and the infinite touched back. Not everyone can say that." +Tu as touché l'infini et l'infini t'a touché en retour. Tout le monde ne peut pas en dire autant. + +#echo-microscopic // "Everything shrinks, then expands. Cells divide in the corner of your vision." +Tout rétrécit, puis s'étend. Des cellules se divisent au coin de ta vision. + +#echo-microscopic-words // "You went smaller than small. You found universes inside atoms inside boxes." +Tu es allé plus petit que le petit. Tu as trouvé des univers dans les atomes dans les boîtes. + +#echo-darkfantasy // "Shadows crawl across the floor. A candle flickers that was never lit." +Des ombres rampent sur le sol. Une bougie vacille qui n'a jamais été allumée. + +#echo-darkfantasy-words // "You walked through the dark and opened boxes that whispered back. Bold." +Tu as traversé les ténèbres et ouvert des boîtes qui murmuraient en retour. Audacieux. + +#gallery-count // "$echoes echoes. $echoes worlds you walked through. Each one left a mark on you, and you on it." +$echoes échos. $echoes mondes que tu as traversés. Chacun a laissé une marque sur toi, et toi sur lui. + +#alcove-intro // "Now. The echoes are one thing. But some of you -- some of you went deeper." +Maintenant. Les échos, c'est une chose. Mais certains d'entre vous — certains d'entre vous sont allés plus profond. + +#alcove-seams // "You found the hidden seams. The secret folds. The boxes within the boxes." +Tu as trouvé les coutures cachées. Les plis secrets. Les boîtes dans les boîtes. + +#alcove-see // "Let me see which ones you found." +Laisse-moi voir lesquels tu as trouvés. + +#secret-space // "A frequency hums beneath the silence. You remember closing your eyes and listening." +Une fréquence vibre sous le silence. Tu te souviens avoir fermé les yeux et écouté. + +#secret-space-words // "The Box Whisperer. You heard what the space box was truly saying. Few ever do." +Le Murmureur de Boîtes. Tu as entendu ce que la boîte spatiale disait vraiment. Peu y parviennent. + +#secret-medieval // "A scale glimmers on the ground, iridescent and warm to the touch." +Une écaille scintille sur le sol, iridescente et chaude au toucher. + +#secret-medieval-words // "The Dragon Charmer. You chose fire over fear, and the dragon chose you back." +Le Charmeur de Dragons. Tu as choisi le feu plutôt que la peur, et le dragon t'a choisi en retour. + +#secret-darkfantasy // "A drop of crimson hangs suspended in the air, neither falling nor fading." +Une goutte de pourpre reste suspendue dans l'air, ne tombant ni ne s'effaçant. + +#secret-darkfantasy-words // "The Blood Communion. You drank from the dark box and survived. Changed, but whole." +La Communion de Sang. Tu as bu à la boîte sombre et survécu. Changé, mais entier. + +#secret-pirate // "A coin spins endlessly on an invisible surface, never landing." +Une pièce tourne sans fin sur une surface invisible, sans jamais retomber. + +#secret-pirate-words // "One of Us. The pirates accepted you as kin. That's rarer than any treasure." +L'Un des Nôtres. Les pirates t'ont accepté comme l'un des leurs. C'est plus rare que n'importe quel trésor. + +#secret-contemporary // "A velvet rope parts silently, revealing a door that was always there." +Un cordon de velours s'écarte en silence, révélant une porte qui a toujours été là. + +#secret-contemporary-words // "The VIP. You found the door behind the door. The real world behind the real world." +Le VIP. Tu as trouvé la porte derrière la porte. Le vrai monde derrière le vrai monde. + +#secret-sentimental // "A photograph develops from nothing, showing a moment that never happened but feels true." +Une photographie se développe à partir de rien, montrant un instant qui n'a jamais eu lieu mais qui semble vrai. + +#secret-sentimental-words // "True Sight. You saw what the memories were really trying to tell you." +La Vision Vraie. Tu as vu ce que les souvenirs essayaient vraiment de te dire. + +#secret-prehistoric // "A stone tool materializes, impossibly sharp after a million years." +Un outil de pierre se matérialise, impossiblement tranchant après un million d'années. + +#secret-prehistoric-words // "The Champion. Before history had a name, you earned one." +Le Champion. Avant que l'histoire ait un nom, tu en as gagné un. + +#secret-cosmic // "Light bends around you, just for a moment, as if the universe is giving you a hug." +La lumière se courbe autour de toi, juste un instant, comme si l'univers te prenait dans ses bras. + +#secret-cosmic-words // "The Enlightened. You understood the cosmic joke, and you laughed along." +L'Éclairé. Tu as compris la blague cosmique, et tu as ri avec elle. + +#secret-microscopic // "A cell divides in your palm, perfectly, impossibly." +Une cellule se divise dans ta paume, parfaitement, impossiblement. + +#secret-microscopic-words // "The Surgeon. You operated on reality itself and left it better than you found it." +Le Chirurgien. Tu as opéré sur la réalité elle-même et tu l'as laissée en meilleur état que tu ne l'avais trouvée. + +#final-now // "And now we come to it. The last flap of the last box." +Et maintenant nous y voilà. Le dernier rabat de la dernière boîte. + +#simple-question // "You opened every box, but did you truly see what was inside?" +Tu as ouvert chaque boîte, mais as-tu vraiment vu ce qu'il y avait à l'intérieur ? + +#simple-reveal // "Most people open boxes for what they contain. The best open them for what they reveal." +La plupart des gens ouvrent les boîtes pour ce qu'elles contiennent. Les meilleurs les ouvrent pour ce qu'elles révèlent. + +#simple-enough // "You did what you came to do. And that is enough. It is always enough." +Tu as fait ce que tu étais venu faire. Et c'est suffisant. C'est toujours suffisant. + +#simple-remember // "The boxes will remember you passed through. Quietly, the way boxes remember things." +Les boîtes se souviendront de ton passage. Silencieusement, comme les boîtes se souviennent des choses. + +#simple-go // "Now go. There are always more boxes. Even when you think there aren't." +Maintenant va. Il y a toujours d'autres boîtes. Même quand tu penses qu'il n'y en a plus. + +#good-remember // "You looked deeper than most. The boxes remember." +Tu as regardé plus profond que la plupart. Les boîtes s'en souviennent. + +#good-doors // "Where others saw cardboard and tape, you saw doors. And you walked through them." +Là où d'autres voyaient du carton et du ruban adhésif, tu as vu des portes. Et tu les as franchies. + +#good-notevery // "Not every secret was found. Not every fold was unfolded." +Tous les secrets n'ont pas été trouvés. Tous les plis n'ont pas été dépliés. + +#good-trying // "But you tried. And trying is what separates the openers from the observers." +Mais tu as essayé. Et essayer, c'est ce qui sépare les ouvreurs des observateurs. + +#good-echoes // "The echoes you left behind will hum for a long time. I'll make sure of it." +Les échos que tu as laissés derrière toi résonneront longtemps. J'y veillerai. + +#great-seeker // "You are a true seeker. Few have found what you've found." +Tu es un véritable chercheur. Peu ont trouvé ce que tu as trouvé. + +#great-listened // "You didn't just open boxes. You listened to them. You understood them." +Tu n'as pas juste ouvert des boîtes. Tu les as écoutées. Tu les as comprises. + +#great-notaccident // "The secret branches you found -- they weren't hidden by accident." +Les chemins secrets que tu as trouvés — ils n'étaient pas cachés par hasard. + +#great-deserve // "They were hidden because only certain people deserve to find them." +Ils étaient cachés parce que seules certaines personnes méritent de les trouver. + +#great-question // "People who look at a box and see not a container, but a question." +Des gens qui regardent une boîte et voient non pas un contenant, mais une question. + +#great-answers // "You asked the questions. You earned the answers." +Tu as posé les questions. Tu as mérité les réponses. + +#ultimate-wait // "Wait." +Attends. + +#ultimate-nine // "Nine branches. All nine." +Neuf branches. Les neuf. + +#ultimate-understand // "Do you understand what you've done?" +Est-ce que tu comprends ce que tu as fait ? + +#ultimate-thousands // "I have watched thousands of players open millions of boxes." +J'ai observé des milliers de joueurs ouvrir des millions de boîtes. + +#ultimate-few // "Most open and move on. Some pause to look. A few reach in deeper." +La plupart ouvrent et passent à autre chose. Certains s'arrêtent pour regarder. Quelques-uns fouillent plus profond. + +#ultimate-every // "But you -- you found every hidden fold, every whispered secret, every shadow door." +Mais toi — tu as trouvé chaque pli caché, chaque secret murmuré, chaque porte d'ombre. + +#ultimate-rule // "I'm going to break a rule now. The biggest rule. A box is not supposed to do this." +Je vais enfreindre une règle maintenant. La plus grande règle. Une boîte n'est pas censée faire ça. + +#ultimate-you // "I know you're out there. Not \"you\" the character. You. The person holding the device, reading these words." +Je sais que tu es là. Pas \"toi\" le personnage. Toi. La personne qui tient l'appareil, qui lit ces mots. + +#ultimate-curiosity // "This was never just a game about opening boxes. It was about curiosity. Your curiosity." +Ceci n'a jamais été qu'un jeu où l'on ouvre des boîtes. C'était une histoire de curiosité. Ta curiosité. + +#ultimate-willingness // "The willingness to look under, behind, inside, and through. To find the thing behind the thing." +La volonté de regarder dessous, derrière, dedans, et au-delà. De trouver la chose derrière la chose. + +#ultimate-rarest // "That is the rarest thing in any universe, real or cardboard." +C'est la chose la plus rare dans tout univers, réel ou en carton. + +#ultimate-star // "So here. Take this. A Destiny Star. It does nothing. It means everything." +Alors tiens. Prends ça. Une Étoile du Destin. Elle ne fait rien. Elle signifie tout. + +#ultimate-thankyou // "Thank you for playing all the way to the bottom of the box." +Merci d'avoir joué jusqu'au fond de la boîte. + +#farewell-one // "One more thing, before the lid closes." +Encore une chose, avant que le couvercle ne se ferme. + +#farewell-samebox // "Every box you ever opened was really the same box. And that box was you." +Chaque boîte que tu as ouverte était en réalité la même boîte. Et cette boîte, c'était toi. + +#farewell-corny // "Corny? Maybe. True? Absolutely." +Cliché ? Peut-être. Vrai ? Absolument. + +#farewell-goodbye // "Goodbye, opener. It was a pleasure being unboxed by you." +Au revoir, ouvreur. Ce fut un plaisir d'être déballée par toi. diff --git a/content/data/boxes.json b/content/data/boxes.json index bcdf22f..bb01b85 100644 --- a/content/data/boxes.json +++ b/content/data/boxes.json @@ -294,10 +294,7 @@ {"itemDefinitionId": "meta_stat_luck", "weight": 1}, {"itemDefinitionId": "meta_stat_charisma", "weight": 1}, {"itemDefinitionId": "meta_stat_dexterity", "weight": 1}, - {"itemDefinitionId": "meta_stat_wisdom", "weight": 1}, - {"itemDefinitionId": "meta_font_consolas", "weight": 2}, - {"itemDefinitionId": "meta_font_firetruc", "weight": 1}, - {"itemDefinitionId": "meta_font_jetbrains", "weight": 2} + {"itemDefinitionId": "meta_stat_wisdom", "weight": 1} ] } }, diff --git a/content/data/items.json b/content/data/items.json index 8fa79a1..8a43d61 100644 --- a/content/data/items.json +++ b/content/data/items.json @@ -26,10 +26,6 @@ {"id": "meta_stat_charisma", "nameKey": "stat.charisma", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "StatVisibility"], "statType": "Charisma"}, {"id": "meta_stat_dexterity", "nameKey": "stat.dexterity", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "StatVisibility"], "statType": "Dexterity"}, {"id": "meta_stat_wisdom", "nameKey": "stat.wisdom", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "StatVisibility"], "statType": "Wisdom"}, - {"id": "meta_font_consolas", "nameKey": "item.meta_font_consolas", "category": "Meta", "rarity": "Uncommon", "tags": ["Meta", "Font"], "fontStyle": "Consolas"}, - {"id": "meta_font_firetruc", "nameKey": "item.meta_font_firetruc", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "Font"], "fontStyle": "Firetruc"}, - {"id": "meta_font_jetbrains", "nameKey": "item.meta_font_jetbrains", "category": "Meta", "rarity": "Rare", "tags": ["Meta", "Font"], "fontStyle": "Jetbrains"}, - {"id": "cosmetic_hair_short", "nameKey": "cosmetic.hair.short", "category": "Cosmetic", "rarity": "Common", "tags": ["Cosmetic"], "cosmeticSlot": "Hair", "cosmeticValue": "Short"}, {"id": "cosmetic_hair_long", "nameKey": "cosmetic.hair.long", "category": "Cosmetic", "rarity": "Common", "tags": ["Cosmetic"], "cosmeticSlot": "Hair", "cosmeticValue": "Long"}, {"id": "cosmetic_hair_ponytail", "nameKey": "cosmetic.hair.ponytail", "category": "Cosmetic", "rarity": "Uncommon", "tags": ["Cosmetic"], "cosmeticSlot": "Hair", "cosmeticValue": "Ponytail"}, diff --git a/content/strings/en.json b/content/strings/en.json index 0ca962e..c7e97a9 100644 --- a/content/strings/en.json +++ b/content/strings/en.json @@ -78,7 +78,7 @@ "box.meta_resources": "Meta Box - Resources", "box.meta_resources.desc": "Unlock the ability to see what you have. And what you lack.", "box.meta_mastery": "Meta Box - Mastery", - "box.meta_mastery.desc": "Layout, stats, fonts. The final touches of a true box master.", + "box.meta_mastery.desc": "Layout and stats. The final touches of a true box master.", "box.black": "Black Box", "box.black.desc": "Nobody knows what's inside. Not even the box.", "box.story": "Story Box", @@ -145,10 +145,6 @@ "stat.dexterity": "Dexterity", "stat.wisdom": "Wisdom", - "item.meta_font_consolas": "Font: Consolas", - "item.meta_font_firetruc": "Font: Firetruc", - "item.meta_font_jetbrains": "Font: JetBrains Mono", - "cosmetic.hair.none": "Bald", "cosmetic.hair.short": "Short Hair", "cosmetic.hair.long": "Long Hair", @@ -453,7 +449,12 @@ "adventure.name.Microscopic": "Cell Division", "adventure.name.DarkFantasy": "Ashen Wastes", "adventure.name.Destiny": "Gallery of Echoes", + "ui.inventory": "Inventory", "stats.boxes_opened": "Boxes Opened", "stats.title": "Stats", - "misc.welcome": "Welcome, {0}!" + "misc.welcome": "Welcome, {0}!", + "destiny.epilogue": "The lid closes. The box remembers.", + "destiny.continue": "Keep opening boxes (free play)", + "destiny.quit": "Close the box for good", + "destiny.thanks": "Thank you for playing Open The Box." } diff --git a/content/strings/fr.json b/content/strings/fr.json index 9d632ff..4e7566e 100644 --- a/content/strings/fr.json +++ b/content/strings/fr.json @@ -78,7 +78,7 @@ "box.meta_resources": "Boîte Méta - Ressources", "box.meta_resources.desc": "Déverrouille la capacité de voir ce que tu as. Et ce qui te manque.", "box.meta_mastery": "Boîte Méta - La Maîtrise", - "box.meta_mastery.desc": "Mise en page, stats, polices. Les touches finales d'un vrai maître des boîtes.", + "box.meta_mastery.desc": "Mise en page et stats. Les touches finales d'un vrai maître des boîtes.", "box.black": "Boîte noire", "box.black.desc": "Personne ne sait ce qu'il y a dedans. Même pas la boîte.", "box.story": "Boîte à histoire", @@ -145,10 +145,6 @@ "stat.dexterity": "Dextérité", "stat.wisdom": "Sagesse", - "item.meta_font_consolas": "Police : Consolas", - "item.meta_font_firetruc": "Police : Firetruc", - "item.meta_font_jetbrains": "Police : JetBrains Mono", - "cosmetic.hair.none": "Chauve", "cosmetic.hair.short": "Cheveux courts", "cosmetic.hair.long": "Cheveux longs", @@ -453,7 +449,12 @@ "adventure.name.Microscopic": "Division cellulaire", "adventure.name.DarkFantasy": "Terres Cendrées", "adventure.name.Destiny": "Galerie des Échos", + "ui.inventory": "Inventaire", "stats.boxes_opened": "Boîtes ouvertes", "stats.title": "Stats", - "misc.welcome": "Bienvenue, {0} !" + "misc.welcome": "Bienvenue, {0} !", + "destiny.epilogue": "Le couvercle se referme. La boîte se souvient.", + "destiny.continue": "Continuer à ouvrir des boîtes (jeu libre)", + "destiny.quit": "Refermer la boîte pour de bon", + "destiny.thanks": "Merci d'avoir joué à Open The Box." } diff --git a/proposals.md b/proposals.md new file mode 100644 index 0000000..a92ac49 --- /dev/null +++ b/proposals.md @@ -0,0 +1,135 @@ +# Propositions d'améliorations du rendu + +Analyse basée sur la capture de 2 playthroughs (seeds 42 et 777, 15 étapes chacun) et le rapport de pacing complet. + +--- + +## 1. Le vide initial : 30 boîtes sans aucun panel visuel + +**Constat** : Les 2 scénarios montrent "(no panels unlocked yet)" pour les 15 premières étapes (30 boîtes). Les premiers déverrouillages meta sont AutoSave et BoxAnimation — invisibles pour le joueur. Le premier panel visuel (ResourcePanel ou InventoryPanel) n'arrive qu'entre la boîte #32 et #36. + +**Impact** : Le joueur voit uniquement du texte brut pendant 5-10 minutes. L'absence de feedback visuel donne l'impression que le jeu est cassé ou pauvre. + +**Propositions** : +- **A)** Déverrouiller TextColors dès la 1ère boîte ou la rendre gratuite. La couleur est le premier signal que "quelque chose se passe". +- **B)** Offrir un "mini portrait" par défaut (juste la boîte nue `+------+`) dès le départ, sans nécessiter le déverrouillage meta_portrait. Le portrait changerait quand des cosmétiques sont équipés. +- **C)** Réordonner les méta-déverrouillages : les panels visuels (StatsPanel, PortraitPanel) devraient arriver avant les features invisibles (AutoSave, BoxAnimation). Suggestion d'ordre : TextColors → StatsPanel → InventoryPanel → ResourcePanel → ArrowKeySelection → PortraitPanel → ... → AutoSave/BoxAnimation (fin de progression). +- **D)** Ajouter un "panneau de bienvenue" statique visible dès le départ avec un ASCII art de boîte et un compteur de boîtes ouvertes. Remplacé par le vrai layout au fur et à mesure. + +--- + +## 2. Les noms de boîtes apparaissent comme IDs bruts + +**Constat** : Dans le loot, les boîtes reçues affichent `"box_of_boxes"`, `"box_ok_tier"`, etc. au lieu de leurs noms traduits. Seuls les items (non-boîtes) ont leurs noms localisés. + +**Impact** : Rupture d'immersion. Le joueur voit du jargon technique au milieu de noms français. + +**Proposition** : Le flux `ItemReceivedEvent` devrait résoudre le nom via `registry.GetBox()` en fallback quand `registry.GetItem()` retourne null. Le helper `GetLocalizedName()` existe déjà dans Program.cs — il faudrait l'utiliser systématiquement dans le rendu des événements. + +--- + +## 3. Cosmétiques reçus sans portrait pour les voir + +**Constat** : Dès la boîte #3, le joueur reçoit des cosmétiques (Yeux bleus, Cheveux en feu, Lunettes d'aviateur). Mais le PortraitPanel ne se déverrouille qu'à la boîte #131 (seed 42). + +**Impact** : Le joueur accumule 20+ cosmétiques sans jamais voir leur effet. Quand le portrait arrive enfin, il ne sait même plus ce qu'il a. + +**Propositions** : +- **A)** Déverrouiller le portrait bien plus tôt (boîte #10-15), ou le rendre visible par défaut (voir point 1B). +- **B)** Afficher un mini-aperçu ASCII du cosmétique au moment du loot : `"Yeux bleus : | O O |"`. +- **C)** Quand un cosmétique est reçu, l'équiper automatiquement si le slot est vide (actuellement il faut aller dans "Changer d'apparence" manuellement). + +--- + +## 4. Ressources reçues mais invisibles + +**Constat** : Le joueur reçoit Fer, Bronze, Bois, Or dès les premières boîtes, mais le ResourcePanel ne se déverrouille que vers la boîte #36. Les changements de ressources s'affichent en texte fugace (`"Santé: 0 -> 10"`) puis disparaissent au Clear suivant. + +**Proposition** : Afficher un résumé compact des ressources dans le texte de loot quand le ResourcePanel n'est pas encore débloqué : +``` + Vous avez reçu : Fer x1 + [Ressources : Santé 10/100 | Or 5] +``` + +--- + +## 5. Le layout "Full" arrive trop tard + +**Constat** : FullLayout se déverrouille à la boîte #199 (seed 42). Avant ça, les panels s'empilent verticalement en mode séquentiel, même quand 10+ panels sont déverrouillés. + +**Proposition** : Faire de FullLayout un des premiers déverrouillages dès lors qu'on a 3 panels. + +--- + +## 6. Le panneau Chat est toujours vide + +**Constat** : Le ChatPanel affiche "No dialogue yet." en permanence dans le hub. Il ne se remplit que pendant les aventures, mais les aventures ont leur propre flux de rendu (ShowAdventureDialogue). + +## 7. Feedback d'événements trop éphémère + +**Constat** : Les événements (loot, déverrouillage, craft) s'affichent une fois puis disparaissent au prochain `Clear()`. Le joueur doit lire vite avant d'appuyer sur une touche. + +**Propositions** : +- **A)** Garder un historique des N derniers événements affiché dans un panel "Log" (remplace le ChatPanel). +- **B)** En mode texte simple (avant FullLayout), ne pas faire de Clear entre chaque action — laisser le texte défiler comme un terminal classique. + +--- + +## 8. L'annonce FigletText de déverrouillage UI prend trop de place + +**Constat** : `ShowUIFeatureUnlocked` affiche un `FigletText` (ASCII art géant du nom de la feature). Sur un terminal 120×30, ça prend 8-10 lignes + 2 règles = ~12 lignes pour un seul mot. + +**Proposition** : Remplacer par une annonce compacte sur 3 lignes max : +``` +╔═══════════════════════════════════════╗ +║ ★ Panneau d'inventaire débloqué ! ★ ║ +╚═══════════════════════════════════════╝ +``` + +--- + +## 9. Progression des cosmétiques : équipement auto + +**Constat** : Le joueur reçoit un cosmétique et doit manuellement aller dans "Changer d'apparence" pour l'équiper. Rien ne lui indique visuellement qu'il a un nouveau cosmétique à essayer. + +**Propositions** : +- **A)** Auto-équiper le premier cosmétique de chaque slot (le joueur voit immédiatement un changement). +- **B)** Afficher un indicateur `[NEW]` à côté de "Changer d'apparence" quand un cosmétique non équipé est disponible. + +--- + +## 10. Lore fragments : noms trop longs + +**Constat** : Les fragments de lore ont des noms qui sont en fait des phrases complètes : +> "L'Ancien Ordre des Ouvreurs de Boîtes n'a qu'un seul commandement : Tu ouvriras tes boîtes." + +Ces noms débordent de la colonne `Name` (24 chars) de l'inventaire et sont tronqués à l'incompréhensible. + +**Proposition** : Donner aux fragments de lore des noms courts (`"Fragment #1"`, `"Décret des Ouvreurs"`) et afficher le texte complet dans une description séparée ou un panel Lore dédié. + +--- + +## 11. Consommables non consommables. + +**Constat** : Les consommables n'ont pas d'action pour être utilisés. + +**Propositions** : +- **A)** rendre les objets du menu d'inventaire sélectionnables (en surbrillance). +- **B)** Lorsqu'ils sont mise en surbrillance un cadre description permet d'afficher le contenu du fragment de lore ou indique qu'appuyer sur la touche "entrée" permet d'activer l'effet. + +--- + +## Résumé des priorités + +| # | Proposition | Impact | Effort | +|---|------------|--------|--------| +| 1C | Réordonner les méta-déverrouillages | Très fort | Faible (JSON) | +| 1B | Portrait visible par défaut | Fort | Moyen | +| 2 | Noms de boîtes localisés dans le loot | Fort | Faible | +| 3C | Auto-équiper le 1er cosmétique | Moyen | Faible | +| 6A | Chat comme log d'événements | Fort | Moyen | +| 8 | Annonce compacte des déverrouillages | Moyen | Faible | +| 5 | FullLayout plus tôt | Moyen | Faible (JSON) | +| 10 | Noms courts pour lore fragments | Moyen | Faible (JSON) | +| 4 | Résumé ressources inline | Faible | Moyen | +| 7B | Pas de Clear avant FullLayout | Moyen | Faible | diff --git a/src/OpenTheBox/Core/Characters/PlayerAppearance.cs b/src/OpenTheBox/Core/Characters/PlayerAppearance.cs index 73ecc6a..a7d5799 100644 --- a/src/OpenTheBox/Core/Characters/PlayerAppearance.cs +++ b/src/OpenTheBox/Core/Characters/PlayerAppearance.cs @@ -5,13 +5,44 @@ namespace OpenTheBox.Core.Characters; /// /// Visual appearance configuration for the player character. /// -public sealed record PlayerAppearance +public sealed class PlayerAppearance { - public HairStyle HairStyle { get; init; } - public TintColor HairTint { get; init; } - public EyeStyle EyeStyle { get; init; } - public BodyStyle BodyStyle { get; init; } - public LegStyle LegStyle { get; init; } - public ArmStyle ArmStyle { get; init; } - public TintColor BodyTint { get; init; } + public HairStyle HairStyle { get; set; } + public TintColor HairTint { get; set; } + public EyeStyle EyeStyle { get; set; } + public BodyStyle BodyStyle { get; set; } + public LegStyle LegStyle { get; set; } + public ArmStyle ArmStyle { get; set; } + public TintColor BodyTint { get; set; } + + /// + /// Applies a cosmetic value to the appropriate slot on this appearance. + /// + public bool ApplyCosmetic(CosmeticSlot slot, string value) + { + switch (slot) + { + case CosmeticSlot.Hair: + if (Enum.TryParse(value, ignoreCase: true, out var hair)) + { HairStyle = hair; return true; } + break; + case CosmeticSlot.Eyes: + if (Enum.TryParse(value, ignoreCase: true, out var eyes)) + { EyeStyle = eyes; return true; } + break; + case CosmeticSlot.Body: + if (Enum.TryParse(value, ignoreCase: true, out var body)) + { BodyStyle = body; return true; } + break; + case CosmeticSlot.Legs: + if (Enum.TryParse(value, ignoreCase: true, out var legs)) + { LegStyle = legs; return true; } + break; + case CosmeticSlot.Arms: + if (Enum.TryParse(value, ignoreCase: true, out var arms)) + { ArmStyle = arms; return true; } + break; + } + return false; + } } diff --git a/src/OpenTheBox/Core/Enums/FontStyle.cs b/src/OpenTheBox/Core/Enums/FontStyle.cs deleted file mode 100644 index 833c6d8..0000000 --- a/src/OpenTheBox/Core/Enums/FontStyle.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace OpenTheBox.Core.Enums; - -/// -/// Unlockable font styles for the CLI interface. -/// Found in Boite Meta. The default console font is always available; -/// additional fonts are unlocked as drops. -/// -public enum FontStyle -{ - /// The default system console font. Always available. - Default, - - /// Consolas monospaced font. Clean and technical. - Consolas, - - /// Firetruc display font. Bold and playful. - Firetruc, - - /// JetBrains Mono font. Developer-focused with ligatures. - Jetbrains, - - /// Vercel One font. Modern and sleek. - VercelOne, - - /// Toto Posted One font. Artistic and distinctive. - TotoPostedOne -} diff --git a/src/OpenTheBox/Core/GameState.cs b/src/OpenTheBox/Core/GameState.cs index 19db0b0..ad790f7 100644 --- a/src/OpenTheBox/Core/GameState.cs +++ b/src/OpenTheBox/Core/GameState.cs @@ -33,7 +33,6 @@ public sealed class GameState public required Locale CurrentLocale { get; set; } public required DateTime CreatedAt { get; set; } public required TimeSpan TotalPlayTime { get; set; } - public HashSet AvailableFonts { get; set; } = []; public HashSet AvailableTextColors { get; set; } = []; public List ActiveCraftingJobs { get; set; } = []; @@ -103,7 +102,6 @@ public sealed class GameState CurrentLocale = locale, CreatedAt = DateTime.UtcNow, TotalPlayTime = TimeSpan.Zero, - AvailableFonts = [], AvailableTextColors = [], ActiveCraftingJobs = [] }; diff --git a/src/OpenTheBox/Core/Items/ItemDefinition.cs b/src/OpenTheBox/Core/Items/ItemDefinition.cs index 19d5e25..013c4df 100644 --- a/src/OpenTheBox/Core/Items/ItemDefinition.cs +++ b/src/OpenTheBox/Core/Items/ItemDefinition.cs @@ -21,6 +21,5 @@ public sealed record ItemDefinition( MaterialForm? MaterialForm = null, WorkstationType? WorkstationType = null, AdventureTheme? AdventureTheme = null, - StatType? StatType = null, - FontStyle? FontStyle = null + StatType? StatType = null ); diff --git a/src/OpenTheBox/OpenTheBox.csproj b/src/OpenTheBox/OpenTheBox.csproj index 3c8e29c..081d7ec 100644 --- a/src/OpenTheBox/OpenTheBox.csproj +++ b/src/OpenTheBox/OpenTheBox.csproj @@ -16,6 +16,7 @@ + diff --git a/src/OpenTheBox/Program.cs b/src/OpenTheBox/Program.cs index 3778422..e733d7f 100644 --- a/src/OpenTheBox/Program.cs +++ b/src/OpenTheBox/Program.cs @@ -6,7 +6,9 @@ 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; @@ -28,16 +30,56 @@ public static class Program private static readonly string LogFilePath = Path.Combine( AppContext.BaseDirectory, "openthebox-error.log"); + private static bool _useTui; + public static async Task Main(string[] args) { + _useTui = args.Contains("--tui"); + + // --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(); - _renderer = RendererFactory.Create(_renderContext, _loc, _registry); - await MainMenuLoop(); + if (_useTui) + { + var tuiRenderer = new TerminalGuiRenderer(_renderContext, _loc, _registry); + _renderer = tuiRenderer; + tuiRenderer.Initialize(); + + // Run game loop on a background thread; Terminal.Gui owns the main thread + _ = Task.Run(async () => + { + try + { + if (snapshotSlot > 0) + await LoadSnapshot(snapshotSlot); + else + await MainMenuLoop(); + } + catch (Exception ex) { LogError(ex); } + finally { Terminal.Gui.Application.Invoke(() => Terminal.Gui.Application.RequestStop()); } + }); + + tuiRenderer.Run(); + tuiRenderer.Dispose(); + } + else + { + RefreshRenderer(); + if (snapshotSlot > 0) + await LoadSnapshot(snapshotSlot); + else + await MainMenuLoop(); + } } catch (Exception ex) { @@ -64,6 +106,24 @@ public static class Program } } + /// + /// In TUI mode, updates the existing renderer context instead of creating a new one. + /// In classic mode, creates a new renderer via the factory. + /// + private static void RefreshRenderer() + { + if (_useTui && _renderer is TerminalGuiRenderer tui) + { + tui.UpdateContext(_renderContext); + if (_registry is not null) + tui.UpdateRegistry(_registry); + } + else if (!_useTui) + { + _renderer = RendererFactory.Create(_renderContext, _loc, _registry); + } + } + private static async Task MainMenuLoop() { // Check for existing saves to determine startup flow @@ -77,7 +137,7 @@ public static class Program if (recentState != null) { _loc.Change(recentState.CurrentLocale); - _renderer = RendererFactory.Create(_renderContext, _loc, _registry); + RefreshRenderer(); } } else @@ -88,7 +148,7 @@ public static class Program int langChoice = _renderer.ShowSelection("Language / Langue", langOptions); var selectedLocale = langChoice == 0 ? Locale.EN : Locale.FR; _loc.Change(selectedLocale); - _renderer = RendererFactory.Create(_renderContext, _loc, _registry); + RefreshRenderer(); } while (_running) @@ -227,6 +287,36 @@ public static class Program 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( @@ -238,7 +328,7 @@ public static class Program _simulation = new GameSimulation(_registry); _craftingEngine = new CraftingEngine(); _renderContext = RenderContext.FromGameState(_state); - _renderer = RendererFactory.Create(_renderContext, _loc, _registry); + RefreshRenderer(); } private static void ChangeLanguage() @@ -252,7 +342,7 @@ public static class Program if (_state != null) _state.CurrentLocale = newLocale; - _renderer = RendererFactory.Create(_renderContext, _loc, _registry); + RefreshRenderer(); } private static async Task GameLoop() @@ -405,7 +495,7 @@ public static class Program case UIFeatureUnlockedEvent uiEvt: _renderContext.Unlock(uiEvt.Feature); - _renderer = RendererFactory.Create(_renderContext, _loc, _registry); + RefreshRenderer(); _renderer.ShowUIFeatureUnlocked( _loc.Get(GetUIFeatureLocKey(uiEvt.Feature))); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); @@ -479,7 +569,6 @@ public static class Program private static void ShowInventory() { - _renderer.Clear(); if (_state.Inventory.Count == 0) { _renderer.ShowMessage(_loc.Get("inventory.empty")); @@ -487,25 +576,52 @@ public static class Program 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(); + if (_useTui) + { + // In TUI mode, the inventory is always visible in its panel. + // Just show a message and wait — the panel already displays the items. + _renderer.ShowMessage(_loc.Get("ui.inventory")); + _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); + return; + } - _renderer.ShowLootReveal(grouped); - _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); + int totalItems = InventoryPanel.GetItemCount(_state); + int maxOffset = Math.Max(0, totalItems - InventoryPanel.MaxVisibleRows); + int scrollOffset = 0; + bool scrollable = totalItems > InventoryPanel.MaxVisibleRows; + + while (true) + { + _renderer.Clear(); + AnsiConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset)); + if (scrollable) + AnsiConsole.MarkupLine("[dim]↑↓ PgUp/PgDn: scroll | Esc/Q: back[/]"); + else + AnsiConsole.MarkupLine($"[dim]{Markup.Escape(_loc.Get("prompt.press_key"))}[/]"); + + var key = Console.ReadKey(intercept: true); + switch (key.Key) + { + case ConsoleKey.UpArrow: + scrollOffset = Math.Max(0, scrollOffset - 1); + break; + case ConsoleKey.DownArrow: + scrollOffset = Math.Min(maxOffset, scrollOffset + 1); + break; + case ConsoleKey.PageUp: + scrollOffset = Math.Max(0, scrollOffset - InventoryPanel.MaxVisibleRows); + break; + case ConsoleKey.PageDown: + scrollOffset = Math.Min(maxOffset, scrollOffset + InventoryPanel.MaxVisibleRows); + break; + case ConsoleKey.Escape: + case ConsoleKey.Q: + return; + default: + if (!scrollable) return; // any key exits if not scrollable + break; + } + } } private static async Task StartAdventure() @@ -521,7 +637,7 @@ public static class Program var options = available.Select(a => { bool completed = _state.CompletedAdventures.Contains(a.ToString()); - string prefix = completed ? $"[[{_loc.Get("adventure.done")}]] " : ""; + string prefix = completed ? $"[{_loc.Get("adventure.done")}] " : ""; return prefix + GetAdventureName(a); }).ToList(); options.Add(_loc.Get("menu.back")); @@ -546,6 +662,26 @@ public static class Program } _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")); + _running = false; + return; + } + } } catch (FileNotFoundException) { @@ -655,17 +791,14 @@ public static class Program var totalUIFeatures = Enum.GetValues().Length; var totalResources = Enum.GetValues().Length; var totalStats = Enum.GetValues().Length; - var totalFonts = Enum.GetValues().Length; - var total = totalCosmetics + totalAdventures + totalUIFeatures - + totalResources + totalStats + totalFonts; + + totalResources + totalStats; var unlocked = _state.UnlockedCosmetics.Count + _state.UnlockedAdventures.Count + _state.UnlockedUIFeatures.Count + _state.VisibleResources.Count - + _state.VisibleStats.Count - + _state.AvailableFonts.Count; + + _state.VisibleStats.Count; _renderContext.CompletionPercent = total > 0 ? (int)(unlocked * 100.0 / total) : 0; } diff --git a/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs b/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs index 68b8ea1..d2ca992 100644 --- a/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs +++ b/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs @@ -1,6 +1,7 @@ using OpenTheBox.Core; using OpenTheBox.Core.Enums; using OpenTheBox.Core.Items; +using OpenTheBox.Data; using OpenTheBox.Localization; using Spectre.Console; using Spectre.Console.Rendering; @@ -12,54 +13,117 @@ namespace OpenTheBox.Rendering.Panels; /// public static class InventoryPanel { + private const int MaxNameWidth = 24; + public const int MaxVisibleRows = 15; + /// Compact row count for inline layout mode (fits in ~24-line terminals). + public const int CompactVisibleRows = 6; + + /// + /// Returns the total number of distinct item groups in the inventory. + /// + public static int GetItemCount(GameState state) => + state.Inventory.GroupBy(i => i.DefinitionId).Count(); + /// /// Builds a renderable inventory table from the current game state. - /// Uses the localization manager to resolve item name keys. + /// Uses the registry to resolve category, rarity, and localized names. /// - public static IRenderable Render(GameState state, LocalizationManager? loc = null) + /// + /// Builds a renderable inventory table. + /// uses fewer visible rows for inline layout mode. + /// + public static IRenderable Render( + GameState state, + ContentRegistry? registry = null, + LocalizationManager? loc = null, + int scrollOffset = 0, + bool compact = false) { + int maxRows = compact ? CompactVisibleRows : MaxVisibleRows; + var table = new Table() .Border(TableBorder.Rounded) - .Title("[bold yellow]Inventory[/]") - .AddColumn(new TableColumn("[bold]Name[/]")) - .AddColumn(new TableColumn("[bold]Category[/]").Centered()) + .AddColumn(new TableColumn("[bold]Name[/]").Width(MaxNameWidth)) + .AddColumn(new TableColumn("[bold]Cat.[/]").Centered()) .AddColumn(new TableColumn("[bold]Rarity[/]").Centered()) .AddColumn(new TableColumn("[bold]Qty[/]").RightAligned()); - // Group items by their definition id and display grouped + // Group items by definition id, sorted by category then name var grouped = state.Inventory .GroupBy(i => i.DefinitionId) - .OrderBy(g => g.Key); + .Select(g => + { + var def = registry?.GetItem(g.Key); + return new + { + DefId = g.Key, + Def = def, + TotalQty = g.Sum(i => i.Quantity), + Category = def?.Category ?? ItemCategory.Box, + Rarity = def?.Rarity ?? ItemRarity.Common + }; + }) + .OrderBy(x => x.Category) + .ThenBy(x => x.DefId) + .ToList(); - foreach (var group in grouped) + int totalItems = grouped.Count; + int clampedOffset = Math.Clamp(scrollOffset, 0, Math.Max(0, totalItems - maxRows)); + var visible = grouped.Skip(clampedOffset).Take(maxRows).ToList(); + + foreach (var item in visible) { - string defId = group.Key; - int totalQty = group.Sum(i => i.Quantity); + // Resolve localized name, truncate if needed + string name = item.Def is not null && loc is not null + ? loc.Get(item.Def.NameKey) + : item.DefId; + if (name.Length > MaxNameWidth) + name = name[..(MaxNameWidth - 1)] + "…"; - // Use localization if available, otherwise fall back to definition id - string name = loc is not null ? loc.Get(defId) : defId; - - // We display the definition id as a stand-in for category/rarity since - // we only have ItemInstance at runtime. The full lookup would go through - // an item registry; for now show the raw id. - string category = "-"; - string rarity = "-"; - string color = "white"; + string category = item.Category.ToString(); + string rarity = item.Rarity.ToString(); + string color = RarityColor(item.Rarity); table.AddRow( $"[{color}]{Markup.Escape(name)}[/]", - Markup.Escape(category), + $"[dim]{Markup.Escape(category)}[/]", $"[{color}]{Markup.Escape(rarity)}[/]", - totalQty.ToString()); + item.TotalQty.ToString()); } - if (!state.Inventory.Any()) + if (totalItems == 0) { - table.AddRow("[dim]Empty[/]", "", "", ""); + string emptyText = loc?.Get("inventory.empty") ?? "Empty"; + table.AddRow($"[dim]{Markup.Escape(emptyText)}[/]", "", "", ""); + } + + // Build header with scroll indicator + string headerText = loc?.Get("ui.inventory") ?? "Inventory"; + string header; + if (totalItems > maxRows) + { + int from = clampedOffset + 1; + int to = Math.Min(clampedOffset + maxRows, totalItems); + header = $"[bold yellow]{Markup.Escape(headerText)}[/] [dim]({from}-{to}/{totalItems})[/]"; + } + else + { + header = $"[bold yellow]{Markup.Escape(headerText)}[/]"; } return new Panel(table) - .Header("[bold yellow]Inventory[/]") + .Header(new PanelHeader(header)) .Border(BoxBorder.Rounded); } + + private static string RarityColor(ItemRarity rarity) => rarity switch + { + ItemRarity.Common => "white", + ItemRarity.Uncommon => "green", + ItemRarity.Rare => "blue", + ItemRarity.Epic => "purple", + ItemRarity.Legendary => "gold1", + ItemRarity.Mythic => "red", + _ => "white" + }; } diff --git a/src/OpenTheBox/Rendering/Panels/PortraitPanel.cs b/src/OpenTheBox/Rendering/Panels/PortraitPanel.cs index f6de77e..0bfd6a2 100644 --- a/src/OpenTheBox/Rendering/Panels/PortraitPanel.cs +++ b/src/OpenTheBox/Rendering/Panels/PortraitPanel.cs @@ -6,13 +6,13 @@ using Spectre.Console.Rendering; namespace OpenTheBox.Rendering.Panels; /// -/// Generates simple ASCII art for the player character based on equipped cosmetics. -/// Different styles change individual pieces of the portrait. +/// Generates simple ASCII art for the player's box character based on equipped cosmetics. +/// The character is a box — cosmetics change what's on/around the box. /// public static class PortraitPanel { /// - /// Builds a renderable ASCII art portrait from the player's appearance settings. + /// Builds a renderable ASCII art box portrait from the player's appearance settings. /// public static IRenderable Render(PlayerAppearance appearance) { @@ -22,15 +22,25 @@ public static class PortraitPanel string legs = GetLegArt(appearance.LegStyle); string arms = GetArmArt(appearance.ArmStyle); - string hairColor = TintToColor(appearance.HairTint); - string bodyColor = TintToColor(appearance.BodyTint); + // Use tint if set, otherwise use style-specific intrinsic color + string hairColor = appearance.HairTint != TintColor.None + ? TintToColor(appearance.HairTint) + : HairStyleColor(appearance.HairStyle); + string eyeColor = EyeStyleColor(appearance.EyeStyle); + string bodyColor = appearance.BodyTint != TintColor.None + ? TintToColor(appearance.BodyTint) + : BodyStyleColor(appearance.BodyStyle); + string legColor = LegStyleColor(appearance.LegStyle); + string armColor = ArmStyleColor(appearance.ArmStyle); string portrait = string.Join(Environment.NewLine, $"[{hairColor}]{Markup.Escape(hair)}[/]", - $"[white]{Markup.Escape(eyes)}[/]", + $"[{bodyColor}] +------+ [/]", + $"[{eyeColor}]{Markup.Escape(eyes)}[/]", $"[{bodyColor}]{Markup.Escape(body)}[/]", - $"[{bodyColor}]{Markup.Escape(legs)}[/]", - $"[{bodyColor}]{Markup.Escape(arms)}[/]"); + $"[{bodyColor}] +------+ [/]", + $"[{legColor}]{Markup.Escape(legs)}[/]", + $"[{armColor}]{Markup.Escape(arms)}[/]"); return new Panel(new Markup(portrait)) .Header("[bold green]Portrait[/]") @@ -38,82 +48,140 @@ public static class PortraitPanel .Padding(1, 0); } - // ── Hair styles ───────────────────────────────────────────────────── + // ── Hair styles (on top of the box) ─────────────────────────────── private static string GetHairArt(HairStyle style) => style switch { - HairStyle.None => " .-. ", - HairStyle.Short => " ~~~ ", - HairStyle.Long => " ~~~~~ ", - HairStyle.Ponytail => " ~~~\\ ", - HairStyle.Braided => " ///\\\\\\ ", - HairStyle.Cyberpunk => " /\\/\\/\\ ", - HairStyle.Fire => " ||| ", - HairStyle.StardustLegendary => " @@@@@ ", - _ => " ??? " + HairStyle.None => " ", + HairStyle.Short => " ~~~~ ", + HairStyle.Long => " ~~~~~~ ", + HairStyle.Ponytail => " ~~~~\\ ", + HairStyle.Braided => " ///\\\\\\ ", + HairStyle.Cyberpunk => " /\\/\\/\\ ", + HairStyle.Fire => " /||\\ ", + HairStyle.StardustLegendary => " *.***.*. ", + _ => " " }; - // ── Eye styles ────────────────────────────────────────────────────── + // ── Eye styles (on the box face) ────────────────────────────────── private static string GetEyeArt(EyeStyle style) => style switch { - EyeStyle.None => " ( o.o ) ", - EyeStyle.Blue => " ( O.O ) ", - EyeStyle.Green => " ( -._ ) ", - EyeStyle.RedOrange => " ( >.< ) ", - EyeStyle.Brown => " ( ^.o ) ", - EyeStyle.Black => " ( -.- ) ", - EyeStyle.Sunglasses => " ( B-) ) ", - EyeStyle.PilotGlasses => " ( B-) ) ", - EyeStyle.AircraftGlasses => " ( B-) ) ", - EyeStyle.CyberneticEyes => " ( *.* ) ", - EyeStyle.MagicianGlasses => " ( o.o ) ", - _ => " ( ?.? ) " + EyeStyle.None => " | o o | ", + EyeStyle.Blue => " | O O | ", + EyeStyle.Green => " | - . | ", + EyeStyle.RedOrange => " | > < | ", + EyeStyle.Brown => " | ^ o | ", + EyeStyle.Black => " | - - | ", + EyeStyle.Sunglasses => " | B ) | ", + EyeStyle.PilotGlasses => " |[B )]| ", + EyeStyle.AircraftGlasses => " |{B )}| ", + EyeStyle.CyberneticEyes => " | * * | ", + EyeStyle.MagicianGlasses => " | @ @ | ", + _ => " | ? ? | " }; - // ── Body styles ───────────────────────────────────────────────────── + // ── Body styles (box decoration) ────────────────────────────────── private static string GetBodyArt(BodyStyle style) => style switch { - BodyStyle.Naked => " |[---]| ", - BodyStyle.RegularTShirt => " |[===]| ", - BodyStyle.SexyTShirt => " |[~~~]| ", - BodyStyle.Suit => " |[###]| ", - BodyStyle.Armored => " |{===}| ", - BodyStyle.Robotic => " \\(===)/ ", - _ => " |[???]| " + BodyStyle.Naked => " | | ", + BodyStyle.RegularTShirt => " | ==== | ", + BodyStyle.SexyTShirt => " | ~<<~ | ", + BodyStyle.Suit => " | #### | ", + BodyStyle.Armored => " |{####}| ", + BodyStyle.Robotic => " |[0110]| ", + _ => " | | " }; - // ── Leg styles ────────────────────────────────────────────────────── + // ── Leg styles (under the box) ──────────────────────────────────── private static string GetLegArt(LegStyle style) => style switch { - LegStyle.None => " | | ", - LegStyle.Naked => " | | ", - LegStyle.Slip => " | | ", - LegStyle.Short => " | | ", - LegStyle.Panty => " | | ", - LegStyle.RocketBoots => " [| |] ", - LegStyle.PegLeg => " |/ ", - LegStyle.Tentacles => " {| |} ", - _ => " | | " + LegStyle.None => " ", + LegStyle.Naked => " | | ", + LegStyle.Slip => " | | ", + LegStyle.Short => " || || ", + LegStyle.Panty => " | | ", + LegStyle.RocketBoots => " [| |] ", + LegStyle.PegLeg => " | / ", + LegStyle.Tentacles => " }{| |}{ ", + _ => " | | " }; - // ── Arm styles ────────────────────────────────────────────────────── + // ── Arm styles (sides of the box) ───────────────────────────────── private static string GetArmArt(ArmStyle style) => style switch { - ArmStyle.None => " / \\ ", - ArmStyle.Short => " / \\ ", - ArmStyle.Regular => " _/ \\_ ", - ArmStyle.Long => " X X ", - ArmStyle.Mechanical => " / ~ ", - ArmStyle.Wings => " ", - ArmStyle.ExtraPair => " ", - _ => " / \\ " + ArmStyle.None => " ", + ArmStyle.Short => " / \\ ", + ArmStyle.Regular => " _/ \\_ ", + ArmStyle.Long => " __/ \\__ ", + ArmStyle.Mechanical => " ~/ \\~ ", + ArmStyle.Wings => " ", + ArmStyle.ExtraPair => " ", + _ => " " }; - // ── Tint mapping ──────────────────────────────────────────────────── + // ── Intrinsic style colors (used when no tint override is set) ──── + + private static string HairStyleColor(HairStyle style) => style switch + { + HairStyle.None => "white", + HairStyle.Short => "silver", + HairStyle.Long => "silver", + HairStyle.Ponytail => "orange1", + HairStyle.Braided => "mediumpurple1", + HairStyle.Cyberpunk => "aqua", + HairStyle.Fire => "red", + HairStyle.StardustLegendary => "gold1", + _ => "white" + }; + + private static string EyeStyleColor(EyeStyle style) => style switch + { + EyeStyle.None => "white", + EyeStyle.Blue => "dodgerblue1", + EyeStyle.Green => "green", + EyeStyle.RedOrange => "orangered1", + EyeStyle.Brown => "orange3", + EyeStyle.Black => "grey", + EyeStyle.Sunglasses => "grey", + EyeStyle.PilotGlasses => "gold1", + EyeStyle.AircraftGlasses => "silver", + EyeStyle.CyberneticEyes => "aqua", + EyeStyle.MagicianGlasses => "purple", + _ => "white" + }; + + private static string BodyStyleColor(BodyStyle style) => style switch + { + BodyStyle.Naked => "white", + BodyStyle.RegularTShirt => "white", + BodyStyle.SexyTShirt => "deeppink1", + BodyStyle.Suit => "grey", + BodyStyle.Armored => "silver", + BodyStyle.Robotic => "aqua", + _ => "white" + }; + + private static string LegStyleColor(LegStyle style) => style switch + { + LegStyle.RocketBoots => "orange1", + LegStyle.PegLeg => "orange3", + LegStyle.Tentacles => "mediumpurple1", + _ => "white" + }; + + private static string ArmStyleColor(ArmStyle style) => style switch + { + ArmStyle.Mechanical => "silver", + ArmStyle.Wings => "gold1", + ArmStyle.ExtraPair => "aqua", + _ => "white" + }; + + // ── Tint mapping (explicit tint overrides intrinsic color) ────── private static string TintToColor(TintColor tint) => tint switch { diff --git a/src/OpenTheBox/Rendering/SpectreRenderer.cs b/src/OpenTheBox/Rendering/SpectreRenderer.cs index 7fc48d2..dcc1058 100644 --- a/src/OpenTheBox/Rendering/SpectreRenderer.cs +++ b/src/OpenTheBox/Rendering/SpectreRenderer.cs @@ -5,6 +5,7 @@ using OpenTheBox.Data; using OpenTheBox.Localization; using OpenTheBox.Rendering.Panels; using Spectre.Console; +using Spectre.Console.Rendering; namespace OpenTheBox.Rendering; @@ -120,13 +121,15 @@ public sealed class SpectreRenderer : IRenderer { if (_context.HasArrowSelection) { + // Number each option and escape so square brackets are not parsed as Spectre markup + var numbered = options.Select((o, i) => $"{i + 1}. {Markup.Escape(o)}").ToList(); string selected = AnsiConsole.Prompt( new SelectionPrompt() .Title(Markup.Escape(prompt)) .PageSize(10) - .AddChoices(options)); + .AddChoices(numbered)); - return options.IndexOf(selected); + return numbered.IndexOf(selected); } if (_context.HasColors) @@ -303,6 +306,10 @@ public sealed class SpectreRenderer : IRenderer // ── Construction ──────────────────────────────────────────────────── + /// Reference terminal size: 120 columns × 30 rows. + public const int RefWidth = 120; + public const int RefHeight = 30; + private RenderContext _context; private readonly LocalizationManager _loc; private ContentRegistry? _registry; @@ -312,6 +319,10 @@ public sealed class SpectreRenderer : IRenderer _context = context; _loc = loc; _registry = registry; + + // Cap rendering width to reference size so layouts stay consistent + if (AnsiConsole.Profile.Width > RefWidth) + AnsiConsole.Profile.Width = RefWidth; } /// @@ -355,99 +366,95 @@ public sealed class SpectreRenderer : IRenderer }; /// - /// Renders all panels in a full Spectre Layout grid. + /// Renders all panels in a tight tmux-like grid (120×30 reference). + /// Row 1: Portrait | Stats | Resources + /// Row 2: Inventory (compact) | Crafting + Completion /// private void RenderFullLayout(GameState state, RenderContext context) { - var layout = new Layout("Root") - .SplitRows( - new Layout("Top") - .SplitColumns( - new Layout("Portrait"), - new Layout("Stats"), - new Layout("Resources")), - new Layout("Middle") - .SplitColumns( - new Layout("Inventory").Ratio(2), - new Layout("Chat")), - new Layout("Bottom")); + // ── Row 1: Portrait (20col) | Stats (30col) | Resources (fill) ── + var topRow = new Table().NoBorder().HideHeaders().Expand(); + topRow.AddColumn(new TableColumn("c1").Width(20).NoWrap()); + topRow.AddColumn(new TableColumn("c2").Width(30).NoWrap()); + topRow.AddColumn(new TableColumn("c3").NoWrap()); - if (context.HasPortraitPanel) - layout["Portrait"].Update(PortraitPanel.Render(state.Appearance)); - else - layout["Portrait"].Update(new Panel("[dim]???[/]").Header("Portrait")); + topRow.AddRow( + context.HasPortraitPanel + ? PortraitPanel.Render(state.Appearance) + : new Panel("[dim]???[/]").Header("Portrait").Expand(), + context.HasStatsPanel + ? StatsPanel.Render(state, _loc) + : new Panel("[dim]???[/]").Header("Stats").Expand(), + context.HasResourcePanel + ? ResourcePanel.Render(state) + : new Panel("[dim]???[/]").Header("Resources").Expand()); - if (context.HasStatsPanel) - layout["Stats"].Update(StatsPanel.Render(state, _loc)); - else - layout["Stats"].Update(new Panel("[dim]???[/]").Header("Stats")); + AnsiConsole.Write(topRow); - if (context.HasResourcePanel) - layout["Resources"].Update(ResourcePanel.Render(state)); - else - layout["Resources"].Update(new Panel("[dim]???[/]").Header("Resources")); + // ── Row 2: Inventory (60col) | Right stack: Crafting + Chat + Completion ── + var botRow = new Table().NoBorder().HideHeaders().Expand(); + botRow.AddColumn(new TableColumn("c1").Width(60).NoWrap()); + botRow.AddColumn(new TableColumn("c2").NoWrap()); - if (context.HasInventoryPanel) - layout["Inventory"].Update(InventoryPanel.Render(state)); - else - layout["Inventory"].Update(new Panel("[dim]???[/]").Header("Inventory")); + // Left: Inventory + IRenderable leftPanel = context.HasInventoryPanel + ? InventoryPanel.Render(state, _registry, _loc, compact: true) + : new Panel("[dim]???[/]").Header("Inventory").Expand(); - if (context.HasChatPanel) - layout["Chat"].Update(ChatPanel.Render([])); - else - layout["Chat"].Update(new Panel("[dim]???[/]").Header("Chat")); + // Right: stack Crafting + Chat + Completion + var rightItems = new List(); if (context.HasCraftingPanel) - layout["Bottom"].Update(CraftingPanel.Render(state, _registry, _loc)); - else - layout["Bottom"].Update(new Panel("[dim]???[/]").Header("???")); + rightItems.Add(CraftingPanel.Render(state, _registry, _loc)); - AnsiConsole.Write(layout); + if (context.HasChatPanel) + rightItems.Add(ChatPanel.Render([])); if (context.HasCompletionTracker) - { - AnsiConsole.Write(new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]").RuleStyle("cyan")); - } + rightItems.Add(new Markup($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]")); + + IRenderable rightPanel = rightItems.Count > 0 + ? new Rows(rightItems) + : new Panel("[dim]???[/]").Header("???").Expand(); + + botRow.AddRow(leftPanel, rightPanel); + AnsiConsole.Write(botRow); } /// - /// Renders only the panels the player has unlocked, stacked vertically. + /// Renders unlocked panels. Uses side-by-side placement when multiple + /// panels from the same row are unlocked to stay within 30 lines. /// private void RenderSequentialPanels(GameState state, RenderContext context) { - if (context.HasPortraitPanel) - { - AnsiConsole.Write(PortraitPanel.Render(state.Appearance)); - } + // Row 1: group top panels side by side when more than one exists + var topPanels = new List(); + if (context.HasPortraitPanel) topPanels.Add(PortraitPanel.Render(state.Appearance)); + if (context.HasStatsPanel) topPanels.Add(StatsPanel.Render(state, _loc)); + if (context.HasResourcePanel) topPanels.Add(ResourcePanel.Render(state)); - if (context.HasStatsPanel) + if (topPanels.Count > 1) { - AnsiConsole.Write(StatsPanel.Render(state, _loc)); + var row = new Table().NoBorder().HideHeaders().Expand(); + foreach (var _ in topPanels) row.AddColumn(new TableColumn("").NoWrap()); + row.AddRow(topPanels.ToArray()); + AnsiConsole.Write(row); } - - if (context.HasResourcePanel) + else if (topPanels.Count == 1) { - AnsiConsole.Write(ResourcePanel.Render(state)); + AnsiConsole.Write(topPanels[0]); } if (context.HasInventoryPanel) - { - AnsiConsole.Write(InventoryPanel.Render(state)); - } - - if (context.HasChatPanel) - { - AnsiConsole.Write(ChatPanel.Render([])); - } + AnsiConsole.Write(InventoryPanel.Render(state, _registry, _loc, compact: true)); if (context.HasCraftingPanel) - { AnsiConsole.Write(CraftingPanel.Render(state, _registry, _loc)); - } + + if (context.HasChatPanel) + AnsiConsole.Write(ChatPanel.Render([])); if (context.HasCompletionTracker) - { AnsiConsole.Write(new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]").RuleStyle("cyan")); - } } } diff --git a/src/OpenTheBox/Rendering/TerminalGuiRenderer.cs b/src/OpenTheBox/Rendering/TerminalGuiRenderer.cs new file mode 100644 index 0000000..c21db8b --- /dev/null +++ b/src/OpenTheBox/Rendering/TerminalGuiRenderer.cs @@ -0,0 +1,688 @@ +using OpenTheBox.Core; +using OpenTheBox.Core.Characters; +using OpenTheBox.Core.Enums; +using OpenTheBox.Core.Items; +using OpenTheBox.Data; +using OpenTheBox.Localization; +using OpenTheBox.Rendering.Panels; +using Terminal.Gui; + +namespace OpenTheBox.Rendering; + +/// +/// Full-featured renderer using Terminal.Gui for tmux-like panel layout. +/// The game loop runs on a background thread; UI interactions are marshalled +/// to the Terminal.Gui main loop via . +/// Blocking calls (ShowSelection, WaitForKeyPress, etc.) use +/// to synchronize. +/// +public sealed class TerminalGuiRenderer : IRenderer, IDisposable +{ + private RenderContext _context; + private readonly LocalizationManager _loc; + private ContentRegistry? _registry; + + // ── Layout views ───────────────────────────────────────────────── + private Toplevel? _top; + private FrameView? _portraitFrame; + private FrameView? _statsFrame; + private FrameView? _resourcesFrame; + private FrameView? _inventoryFrame; + private FrameView? _craftingFrame; + private FrameView? _chatFrame; + private FrameView? _actionFrame; + private Label? _completionLabel; + private TextView? _messageLog; + + // ── Synchronization ────────────────────────────────────────────── + private readonly ManualResetEventSlim _selectionDone = new(false); + private readonly ManualResetEventSlim _keyPressDone = new(false); + private readonly ManualResetEventSlim _textInputDone = new(false); + private int _selectedIndex = -1; + private string _textInputResult = ""; + private readonly List _chatMessages = []; + + public TerminalGuiRenderer(RenderContext context, LocalizationManager loc, ContentRegistry? registry = null) + { + _context = context; + _loc = loc; + _registry = registry; + } + + /// + /// Initializes Terminal.Gui and builds the layout. Must be called from the main thread. + /// + public void Initialize() + { + Application.Init(); + _top = Application.Top; + BuildLayout(); + } + + /// + /// Runs the Terminal.Gui application loop. Blocks until Application.RequestStop(). + /// + public void Run() + { + if (_top is not null) + Application.Run(_top); + } + + public void UpdateContext(RenderContext context) => _context = context; + public void UpdateRegistry(ContentRegistry registry) => _registry = registry; + + // ── IRenderer: Messages ────────────────────────────────────────── + + public void ShowMessage(string message) + { + AppendChat(null, message); + } + + public void ShowError(string message) + { + AppendChat(null, $"ERROR: {message}"); + } + + // ── IRenderer: Box opening ─────────────────────────────────────── + + public void ShowBoxOpening(string boxName, string rarity) + { + AppendChat(null, _loc.Get("box.opening", boxName)); + Thread.Sleep(_context.HasBoxAnimation ? 1500 : 500); + AppendChat(null, _loc.Get("box.opened_short", boxName)); + } + + // ── IRenderer: Loot reveal ─────────────────────────────────────── + + public void ShowLootReveal(List<(string name, string rarity, string category)> items) + { + AppendChat(null, _loc.Get("loot.received")); + foreach (var (name, rarity, _) in items) + { + AppendChat(null, $" - {name} [{rarity}]"); + } + } + + // ── IRenderer: Selection ───────────────────────────────────────── + + public int ShowSelection(string prompt, List options) + { + _selectionDone.Reset(); + _selectedIndex = 0; + + Application.Invoke(() => + { + if (_actionFrame is null || _top is null) return; + + _actionFrame.RemoveAll(); + + var promptLabel = new Label + { + Text = prompt, + X = 0, + Y = 0, + Width = Dim.Fill(), + ColorScheme = Colors.ColorSchemes["TopLevel"] + }; + + var list = new ListView + { + X = 0, + Y = 1, + Width = Dim.Fill(), + Height = Dim.Fill(), + CanFocus = true + }; + list.SetSource(new System.Collections.ObjectModel.ObservableCollection( + options.Select((o, i) => $" {i + 1}. {o}"))); + list.SelectedItem = 0; + + list.OpenSelectedItem += (_, args) => + { + _selectedIndex = args.Item; + _selectionDone.Set(); + }; + + // Also accept Enter key + list.KeyDown += (_, args) => + { + if (args.KeyCode == KeyCode.Enter) + { + _selectedIndex = list.SelectedItem; + _selectionDone.Set(); + args.Handled = true; + } + // Number keys for direct selection + else if (args.KeyCode >= KeyCode.D1 && args.KeyCode <= KeyCode.D9) + { + int idx = (int)(args.KeyCode - KeyCode.D1); + if (idx < options.Count) + { + _selectedIndex = idx; + _selectionDone.Set(); + args.Handled = true; + } + } + }; + + _actionFrame.Add(promptLabel, list); + _actionFrame.Title = _loc.Get("prompt.choose_action"); + list.SetFocus(); + }); + + _selectionDone.Wait(); + return _selectedIndex; + } + + // ── IRenderer: Text input ──────────────────────────────────────── + + public string ShowTextInput(string prompt) + { + _textInputDone.Reset(); + _textInputResult = ""; + + Application.Invoke(() => + { + if (_actionFrame is null) return; + + _actionFrame.RemoveAll(); + + var label = new Label + { + Text = prompt, + X = 0, + Y = 0, + Width = Dim.Fill() + }; + + var field = new TextField + { + X = 0, + Y = 1, + Width = Dim.Fill(), + CanFocus = true + }; + + field.KeyDown += (_, args) => + { + if (args.KeyCode == KeyCode.Enter) + { + _textInputResult = field.Text ?? ""; + _textInputDone.Set(); + args.Handled = true; + } + }; + + _actionFrame.Add(label, field); + _actionFrame.Title = prompt; + field.SetFocus(); + }); + + _textInputDone.Wait(); + return _textInputResult; + } + + // ── IRenderer: Game state ──────────────────────────────────────── + + public void ShowGameState(GameState state, RenderContext context) + { + Application.Invoke(() => + { + UpdatePortrait(state); + UpdateStats(state); + UpdateResources(state); + UpdateInventory(state); + UpdateCrafting(state); + + if (_completionLabel is not null && context.HasCompletionTracker) + { + _completionLabel.Text = _loc.Get("ui.completion", context.CompletionPercent); + _completionLabel.Visible = true; + } + else if (_completionLabel is not null) + { + _completionLabel.Visible = false; + } + + // Show/hide panels based on context + SetFrameVisible(_portraitFrame, context.HasPortraitPanel); + SetFrameVisible(_statsFrame, context.HasStatsPanel); + SetFrameVisible(_resourcesFrame, context.HasResourcePanel); + SetFrameVisible(_inventoryFrame, context.HasInventoryPanel); + SetFrameVisible(_craftingFrame, context.HasCraftingPanel); + }); + } + + // ── IRenderer: Adventure ───────────────────────────────────────── + + public void ShowAdventureDialogue(string? character, string text) + { + AppendChat(character, text); + } + + public int ShowAdventureChoice(List options) + { + return ShowSelection(_loc.Get("prompt.what_do"), options); + } + + public void ShowAdventureHint(string hint) + { + AppendChat(null, $" ({hint})"); + } + + // ── IRenderer: UI feature unlock ───────────────────────────────── + + public void ShowUIFeatureUnlocked(string featureName) + { + AppendChat(null, $"★ {_loc.Get("ui.feature_unlocked", featureName)} ★"); + } + + // ── IRenderer: Interaction ─────────────────────────────────────── + + public void ShowInteraction(string description) + { + AppendChat(null, $"* {description} *"); + } + + // ── IRenderer: Wait ────────────────────────────────────────────── + + public void WaitForKeyPress(string? message = null) + { + string text = message ?? _loc.Get("prompt.press_key"); + AppendChat(null, text); + + _keyPressDone.Reset(); + + Application.Invoke(() => + { + if (_actionFrame is null) return; + + _actionFrame.RemoveAll(); + var label = new Label + { + Text = text, + X = Pos.Center(), + Y = Pos.Center(), + Width = Dim.Fill(), + CanFocus = true + }; + label.KeyDown += (_, args) => + { + _keyPressDone.Set(); + args.Handled = true; + }; + _actionFrame.Add(label); + label.SetFocus(); + }); + + _keyPressDone.Wait(); + } + + // ── IRenderer: Clear ───────────────────────────────────────────── + + public void Clear() + { + // In Terminal.Gui mode, we don't clear — the layout persists. + // Instead, update panels on next ShowGameState. + } + + // ── Layout building ────────────────────────────────────────────── + + private void BuildLayout() + { + if (_top is null) return; + + // Top row: Portrait | Stats | Resources (height ~10) + _portraitFrame = new FrameView + { + Title = "Portrait", + X = 0, + Y = 0, + Width = 18, + Height = 12, + Visible = false + }; + + _statsFrame = new FrameView + { + Title = _loc.Get("stats.title"), + X = 18, + Y = 0, + Width = 25, + Height = 12, + Visible = false + }; + + _resourcesFrame = new FrameView + { + Title = "Resources", + X = 43, + Y = 0, + Width = Dim.Fill(), + Height = 12, + Visible = false + }; + + // Middle row: Inventory | Chat (flexible height) + _inventoryFrame = new FrameView + { + Title = _loc.Get("ui.inventory") ?? "Inventory", + X = 0, + Y = 12, + Width = Dim.Percent(50), + Height = 18, + Visible = false + }; + + _chatFrame = new FrameView + { + Title = "Chat", + X = Pos.Percent(50), + Y = 12, + Width = Dim.Fill(), + Height = 18 + }; + + _messageLog = new TextView + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill(), + ReadOnly = true, + WordWrap = true, + CanFocus = false + }; + _chatFrame.Add(_messageLog); + + // Bottom row: Crafting + Completion + _craftingFrame = new FrameView + { + Title = _loc.Get("craft.panel.title") ?? "Workshops", + X = 0, + Y = 30, + Width = Dim.Fill(), + Height = 5, + Visible = false + }; + + _completionLabel = new Label + { + X = 0, + Y = 35, + Width = Dim.Fill(), + Visible = false + }; + + // Action frame: where selections, prompts, inputs go + _actionFrame = new FrameView + { + Title = "Actions", + X = 0, + Y = Pos.AnchorEnd(10), + Width = Dim.Fill(), + Height = 10 + }; + + _top.Add( + _portraitFrame, _statsFrame, _resourcesFrame, + _inventoryFrame, _chatFrame, + _craftingFrame, _completionLabel, + _actionFrame); + } + + // ── Panel update helpers ───────────────────────────────────────── + + private void UpdatePortrait(GameState state) + { + if (_portraitFrame is null) return; + _portraitFrame.RemoveAll(); + + var appearance = state.Appearance; + string art = string.Join("\n", + GetHairArt(appearance.HairStyle), + " +------+", + GetEyeArt(appearance.EyeStyle), + GetBodyArt(appearance.BodyStyle), + " +------+", + GetLegArt(appearance.LegStyle), + GetArmArt(appearance.ArmStyle)); + + _portraitFrame.Add(new Label + { + Text = art, + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() + }); + } + + private void UpdateStats(GameState state) + { + if (_statsFrame is null) return; + _statsFrame.RemoveAll(); + + var lines = new List(); + foreach (var statType in state.VisibleStats.OrderBy(s => s.ToString())) + { + if (state.Stats.TryGetValue(statType, out int value)) + lines.Add($" {statType}: {value}"); + } + + string boxesLabel = _loc.Get("stats.boxes_opened"); + lines.Add($" {boxesLabel}: {state.TotalBoxesOpened}"); + + _statsFrame.Add(new Label + { + Text = string.Join("\n", lines), + X = 0, Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() + }); + } + + private void UpdateResources(GameState state) + { + if (_resourcesFrame is null) return; + _resourcesFrame.RemoveAll(); + + var lines = new List(); + foreach (var rt in state.VisibleResources.OrderBy(r => r.ToString())) + { + if (!state.Resources.TryGetValue(rt, out var res)) continue; + int barW = 20; + int filled = res.Max > 0 ? (int)Math.Round((double)res.Current / res.Max * barW) : 0; + filled = Math.Clamp(filled, 0, barW); + string bar = new string('#', filled) + new string('-', barW - filled); + lines.Add($" {rt}: [{bar}] {res.Current}/{res.Max}"); + } + + if (lines.Count == 0) + lines.Add(" No resources visible yet."); + + _resourcesFrame.Add(new Label + { + Text = string.Join("\n", lines), + X = 0, Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() + }); + } + + private void UpdateInventory(GameState state) + { + if (_inventoryFrame is null || _registry is null) return; + _inventoryFrame.RemoveAll(); + + var grouped = state.Inventory + .GroupBy(i => i.DefinitionId) + .Select(g => + { + var def = _registry.GetItem(g.Key); + string name = def is not null ? _loc.Get(def.NameKey) : g.Key; + string rarity = (def?.Rarity ?? ItemRarity.Common).ToString(); + string category = (def?.Category ?? ItemCategory.Box).ToString(); + int qty = g.Sum(i => i.Quantity); + return $" {name,-24} {category,-12} {rarity,-10} x{qty}"; + }) + .ToList(); + + if (grouped.Count == 0) + grouped.Add($" {_loc.Get("inventory.empty")}"); + + var list = new ListView + { + X = 0, Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill(), + CanFocus = false + }; + list.SetSource(new System.Collections.ObjectModel.ObservableCollection(grouped)); + _inventoryFrame.Add(list); + + string title = _loc.Get("ui.inventory") ?? "Inventory"; + _inventoryFrame.Title = $"{title} ({state.Inventory.GroupBy(i => i.DefinitionId).Count()})"; + } + + private void UpdateCrafting(GameState state) + { + if (_craftingFrame is null) return; + _craftingFrame.RemoveAll(); + + var lines = new List(); + foreach (var job in state.ActiveCraftingJobs.OrderBy(j => j.StartedAt)) + { + string name = job.RecipeId; + if (_registry?.Recipes.TryGetValue(job.RecipeId, out var recipe) == true) + name = _loc.Get(recipe.NameKey); + + string station = job.Workstation.ToString(); + if (job.IsComplete) + lines.Add($" ✓ {station}: {name} — {_loc.Get("craft.done")}"); + else + { + int pct = (int)job.ProgressPercent; + int barW = 20; + int filled = barW * pct / 100; + string bar = new string('#', filled) + new string('-', barW - filled); + lines.Add($" {station}: {name} [{bar}] {pct}%"); + } + } + + if (lines.Count == 0) + lines.Add($" {_loc.Get("craft.panel.empty")}"); + + _craftingFrame.Add(new Label + { + Text = string.Join("\n", lines), + X = 0, Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() + }); + } + + // ── Chat/message log ───────────────────────────────────────────── + + private void AppendChat(string? character, string text) + { + string line = character is not null ? $"[{character}] {text}" : text; + _chatMessages.Add(line); + + // Keep only last 100 messages + if (_chatMessages.Count > 100) + _chatMessages.RemoveRange(0, _chatMessages.Count - 100); + + Application.Invoke(() => + { + if (_messageLog is null) return; + _messageLog.Text = string.Join("\n", _chatMessages); + // Scroll to bottom + _messageLog.MoveEnd(); + }); + } + + // ── Helpers ────────────────────────────────────────────────────── + + private static void SetFrameVisible(FrameView? frame, bool visible) + { + if (frame is not null) frame.Visible = visible; + } + + // ── Box ASCII art (duplicated from PortraitPanel for Terminal.Gui text) ── + + private static string GetHairArt(HairStyle style) => style switch + { + HairStyle.None => " ", + HairStyle.Short => " ~~~~ ", + HairStyle.Long => " ~~~~~~ ", + HairStyle.Ponytail => " ~~~~\\ ", + HairStyle.Braided => " ///\\\\\\ ", + HairStyle.Cyberpunk => " /\\/\\/\\ ", + HairStyle.Fire => " /||\\ ", + HairStyle.StardustLegendary => " *.***.*. ", + _ => " " + }; + + private static string GetEyeArt(EyeStyle style) => style switch + { + EyeStyle.None => " | o o | ", + EyeStyle.Blue => " | O O | ", + EyeStyle.Green => " | - . | ", + EyeStyle.RedOrange => " | > < | ", + EyeStyle.Brown => " | ^ o | ", + EyeStyle.Black => " | - - | ", + EyeStyle.Sunglasses => " | B ) | ", + EyeStyle.PilotGlasses => " |[B )]| ", + EyeStyle.AircraftGlasses => " |{B )}| ", + EyeStyle.CyberneticEyes => " | * * | ", + EyeStyle.MagicianGlasses => " | @ @ | ", + _ => " | ? ? | " + }; + + private static string GetBodyArt(BodyStyle style) => style switch + { + BodyStyle.Naked => " | | ", + BodyStyle.RegularTShirt => " | ==== | ", + BodyStyle.SexyTShirt => " | ~<<~ | ", + BodyStyle.Suit => " | #### | ", + BodyStyle.Armored => " |{####}| ", + BodyStyle.Robotic => " |[0110]| ", + _ => " | | " + }; + + private static string GetLegArt(LegStyle style) => style switch + { + LegStyle.None => " ", + LegStyle.Naked => " | | ", + LegStyle.Slip => " | | ", + LegStyle.Short => " || || ", + LegStyle.Panty => " | | ", + LegStyle.RocketBoots => " [| |] ", + LegStyle.PegLeg => " | / ", + LegStyle.Tentacles => " }{| |}{ ", + _ => " | | " + }; + + private static string GetArmArt(ArmStyle style) => style switch + { + ArmStyle.None => " ", + ArmStyle.Short => " / \\ ", + ArmStyle.Regular => " _/ \\_ ", + ArmStyle.Long => " __/ \\__ ", + ArmStyle.Mechanical => " ~/ \\~ ", + ArmStyle.Wings => " ", + ArmStyle.ExtraPair => " ", + _ => " " + }; + + // ── Dispose ────────────────────────────────────────────────────── + + public void Dispose() + { + _selectionDone.Dispose(); + _keyPressDone.Dispose(); + _textInputDone.Dispose(); + Application.Shutdown(); + } +} diff --git a/src/OpenTheBox/Simulation/BoxEngine.cs b/src/OpenTheBox/Simulation/BoxEngine.cs index c8eee8f..500b8f0 100644 --- a/src/OpenTheBox/Simulation/BoxEngine.cs +++ b/src/OpenTheBox/Simulation/BoxEngine.cs @@ -13,6 +13,19 @@ namespace OpenTheBox.Simulation; /// public class BoxEngine(ContentRegistry registry) { + /// + /// The meta box upgrade chain. When all non-box items in a tier are already obtained, + /// the box automatically upgrades to the next tier. + /// + private static readonly string[] MetaBoxChain = + [ + "box_meta_basics", + "box_meta_interface", + "box_meta_deep", + "box_meta_resources", + "box_meta_mastery" + ]; + /// /// Opens a box, evaluating its loot table against the current game state and returning /// all resulting events (items received, nested box opens, etc.). @@ -20,6 +33,10 @@ public class BoxEngine(ContentRegistry registry) public List Open(string boxDefId, GameState state, Random rng, bool isAutoOpen = false) { var events = new List(); + + // Auto-upgrade meta boxes when all non-box loot is already obtained + boxDefId = ResolveMetaBoxUpgrade(boxDefId, state); + var boxDef = registry.GetBox(boxDefId); if (boxDef is null) return events; @@ -35,41 +52,7 @@ public class BoxEngine(ContentRegistry registry) // Handle weighted random rolls var eligibleEntries = FilterEligibleEntries(boxDef.LootTable, state); // Remove unique-unlock items the player already owns so they don't drop again - eligibleEntries.RemoveAll(e => - { - // Cosmetics: check by definition ID - if (state.UnlockedCosmetics.Contains(e.ItemDefinitionId)) - return true; - - // Check if this is a box with a known adventure theme already unlocked - var boxDef = registry.GetBox(e.ItemDefinitionId); - if (boxDef?.AdventureTheme is { } theme && state.UnlockedAdventures.Contains(theme)) - return true; - - // Look up item definition for field-based dedup - var itemDef = registry.GetItem(e.ItemDefinitionId); - if (itemDef is null) - return false; // Box entries and unknown entries survive the filter - - // Meta UI features already unlocked - if (itemDef.MetaUnlock.HasValue && state.UnlockedUIFeatures.Contains(itemDef.MetaUnlock.Value)) - return true; - - // Resource visibility already unlocked (only Meta items, not consumables) - if (itemDef.ResourceType.HasValue && itemDef.Category == ItemCategory.Meta - && state.VisibleResources.Contains(itemDef.ResourceType.Value)) - return true; - - // Stat visibility already unlocked - if (itemDef.StatType.HasValue && state.VisibleStats.Contains(itemDef.StatType.Value)) - return true; - - // Font already unlocked - if (itemDef.FontStyle.HasValue && state.AvailableFonts.Contains(itemDef.FontStyle.Value)) - return true; - - return false; - }); + eligibleEntries.RemoveAll(e => IsAlreadyObtained(e.ItemDefinitionId, state)); if (boxDef.LootTable.RollCount > 0 && eligibleEntries.Count > 0) { var weightedEntries = eligibleEntries @@ -187,4 +170,76 @@ public class BoxEngine(ContentRegistry registry) _ => false }; } + + /// + /// Checks whether a loot entry's item has already been obtained by the player. + /// + private bool IsAlreadyObtained(string itemDefId, GameState state) + { + // Cosmetics: check by definition ID + if (state.UnlockedCosmetics.Contains(itemDefId)) + return true; + + // Check if this is a box with a known adventure theme already unlocked + var boxDef = registry.GetBox(itemDefId); + if (boxDef?.AdventureTheme is { } theme && state.UnlockedAdventures.Contains(theme)) + return true; + + // Look up item definition for field-based dedup + var itemDef = registry.GetItem(itemDefId); + if (itemDef is null) + return false; + + // Meta UI features already unlocked + if (itemDef.MetaUnlock.HasValue && state.UnlockedUIFeatures.Contains(itemDef.MetaUnlock.Value)) + return true; + + // Resource visibility already unlocked (only Meta items, not consumables) + if (itemDef.ResourceType.HasValue && itemDef.Category == ItemCategory.Meta + && state.VisibleResources.Contains(itemDef.ResourceType.Value)) + return true; + + // Stat visibility already unlocked + if (itemDef.StatType.HasValue && state.VisibleStats.Contains(itemDef.StatType.Value)) + return true; + + return false; + } + + /// + /// If the box is part of the meta box chain and all its non-box loot items are already + /// obtained, upgrades it to the next tier. Repeats until a tier with obtainable loot is + /// found, or the end of the chain is reached. + /// + private string ResolveMetaBoxUpgrade(string boxDefId, GameState state) + { + var chainIndex = Array.IndexOf(MetaBoxChain, boxDefId); + if (chainIndex < 0) + return boxDefId; + + while (chainIndex < MetaBoxChain.Length) + { + var currentId = MetaBoxChain[chainIndex]; + var currentBox = registry.GetBox(currentId); + if (currentBox is null) + break; + + // Check if all non-box loot entries have already been obtained + var nonBoxEntries = currentBox.LootTable.Entries + .Where(e => registry.GetBox(e.ItemDefinitionId) is null) + .ToList(); + + if (nonBoxEntries.Count == 0 || nonBoxEntries.Any(e => !IsAlreadyObtained(e.ItemDefinitionId, state))) + { + // There's still obtainable loot in this tier (or no non-box entries to check) + return currentId; + } + + // All non-box items obtained — try next tier + chainIndex++; + } + + // Reached end of chain; return the last tier + return MetaBoxChain[^1]; + } } diff --git a/src/OpenTheBox/Simulation/GameSimulation.cs b/src/OpenTheBox/Simulation/GameSimulation.cs index b48eb04..c2a81b6 100644 --- a/src/OpenTheBox/Simulation/GameSimulation.cs +++ b/src/OpenTheBox/Simulation/GameSimulation.cs @@ -194,7 +194,16 @@ public class GameSimulation return events; } - events.Add(new CosmeticEquippedEvent(itemDef.CosmeticSlot.Value, itemDef.CosmeticValue)); + var slot = itemDef.CosmeticSlot.Value; + var value = itemDef.CosmeticValue; + + if (!state.Appearance.ApplyCosmetic(slot, value)) + { + events.Add(new MessageEvent("error.cosmetic_apply_failed")); + return events; + } + + events.Add(new CosmeticEquippedEvent(slot, value)); return events; } diff --git a/src/OpenTheBox/Simulation/InteractionEngine.cs b/src/OpenTheBox/Simulation/InteractionEngine.cs index 34533d6..cbe44c1 100644 --- a/src/OpenTheBox/Simulation/InteractionEngine.cs +++ b/src/OpenTheBox/Simulation/InteractionEngine.cs @@ -68,8 +68,8 @@ public class InteractionEngine(ContentRegistry registry) { // Multiple automatic matches: let the player choose events.Add(new ChoiceRequiredEvent( - Prompt: $"Multiple interactions available for '{newItem.DefinitionId}'", - Options: automaticRules.Select(r => r.Id).ToList() + Prompt: "prompt.choose_interaction", + Options: automaticRules.Select(r => r.DescriptionKey).ToList() )); } else diff --git a/src/OpenTheBox/Simulation/MetaEngine.cs b/src/OpenTheBox/Simulation/MetaEngine.cs index d3008c0..72def02 100644 --- a/src/OpenTheBox/Simulation/MetaEngine.cs +++ b/src/OpenTheBox/Simulation/MetaEngine.cs @@ -56,12 +56,6 @@ public class MetaEngine state.VisibleStats.Add(itemDef.StatType.Value); } - // Unlock font if this item references a font style - if (itemDef.FontStyle.HasValue) - { - state.AvailableFonts.Add(itemDef.FontStyle.Value); - } - // Track cosmetic unlocks if (itemDef.CosmeticSlot.HasValue && itemDef.CosmeticValue is not null) { diff --git a/tests/OpenTheBox.Tests/RendererTests.cs b/tests/OpenTheBox.Tests/RendererTests.cs index 2c01ad9..f35c47a 100644 --- a/tests/OpenTheBox.Tests/RendererTests.cs +++ b/tests/OpenTheBox.Tests/RendererTests.cs @@ -333,7 +333,7 @@ public class InventoryPanelTests var state = GameState.Create("Test", Locale.EN); state.AddItem(ItemInstance.Create("health_potion_small")); var loc = new LocalizationManager(Locale.EN); - var result = RenderHelper.RenderToString(InventoryPanel.Render(state, loc)); + var result = RenderHelper.RenderToString(InventoryPanel.Render(state, loc: loc)); Assert.NotEmpty(result); } diff --git a/tests/OpenTheBox.Tests/UnitTest1.cs b/tests/OpenTheBox.Tests/UnitTest1.cs index fb315c6..4bbdc58 100644 --- a/tests/OpenTheBox.Tests/UnitTest1.cs +++ b/tests/OpenTheBox.Tests/UnitTest1.cs @@ -6,9 +6,14 @@ using OpenTheBox.Core.Enums; using OpenTheBox.Core.Interactions; using OpenTheBox.Core.Items; using OpenTheBox.Data; +using OpenTheBox.Localization; +using OpenTheBox.Rendering; +using OpenTheBox.Rendering.Panels; using OpenTheBox.Simulation; using OpenTheBox.Simulation.Actions; using OpenTheBox.Simulation.Events; +using Spectre.Console; +using Spectre.Console.Rendering; using Loreline; namespace OpenTheBox.Tests; @@ -632,6 +637,7 @@ public class ContentValidationTests [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] + [InlineData("destiny")] public void Adventure_FrenchTranslationExists(string theme) { var path = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor"); @@ -686,6 +692,7 @@ public class ContentValidationTests [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] + [InlineData("destiny")] public void Adventure_FrenchTranslationParsesWithoutError(string theme) { var path = Path.Combine(ContentRoot, "adventures", theme, "intro.fr.lor"); @@ -714,6 +721,7 @@ public class ContentValidationTests [InlineData("cosmic")] [InlineData("microscopic")] [InlineData("darkfantasy")] + [InlineData("destiny")] public void Adventure_FrenchTranslationCoversAllTags(string theme) { var enPath = Path.Combine(ContentRoot, "adventures", theme, "intro.lor"); @@ -808,11 +816,6 @@ public class ContentValidationTests .Select(i => i.StatType!.Value) .ToHashSet(); - var expectedFonts = allItems - .Where(i => i.FontStyle.HasValue) - .Select(i => i.FontStyle!.Value) - .ToHashSet(); - // Adventure token recipes: their outputs are also direct drops from adventure boxes, // so they serve as bonus insurance, not mandatory crafting requirements. var adventureRecipeIds = new HashSet @@ -877,11 +880,10 @@ public class ContentValidationTests bool allResources = expectedResources.IsSubsetOf(state.VisibleResources); bool allLore = expectedLore.IsSubsetOf(seenDefinitionIds); bool allStats = expectedStats.IsSubsetOf(state.VisibleStats); - bool allFonts = expectedFonts.IsSubsetOf(state.AvailableFonts); bool allCrafted = expectedCraftedItems.IsSubsetOf(seenDefinitionIds); if (allUIFeatures && allCosmetics && allAdventures && allResources - && allLore && allStats && allFonts && allCrafted) + && allLore && allStats && allCrafted) break; // 100% completion reached } @@ -911,10 +913,6 @@ public class ContentValidationTests Assert.True(missingStats.Count == 0, $"Missing visible stats after {totalBoxesOpened} boxes: {string.Join(", ", missingStats)}"); - var missingFonts = expectedFonts.Except(state.AvailableFonts).ToList(); - Assert.True(missingFonts.Count == 0, - $"Missing fonts after {totalBoxesOpened} boxes: {string.Join(", ", missingFonts)}"); - var missingCrafted = expectedCraftedItems.Except(seenDefinitionIds).ToList(); Assert.True(missingCrafted.Count == 0, $"Missing crafted items after {totalBoxesOpened} boxes: {string.Join(", ", missingCrafted)}"); @@ -1263,6 +1261,297 @@ public class ContentValidationTests Console.WriteLine(report.ToString()); } + // ── Save Snapshot Generator ──────────────────────────────────────── + + /// + /// Generates save files at key progression milestones for visual testing. + /// Snapshots are saved to saves/ as snapshot_1.otb through snapshot_9.otb. + /// Load them in-game with Ctrl+1..9 for instant visual testing. + /// + /// Run with: dotnet test --filter "GenerateSaveSnapshots" --logger "console;verbosity=detailed" + /// + [Fact] + public void GenerateSaveSnapshots() + { + var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); + var simulation = new GameSimulation(registry, new Random(42)); + var craftingEngine = new CraftingEngine(); + var state = GameState.Create("SnapshotPlayer", Locale.FR); + + var starterBox = ItemInstance.Create("box_starter"); + state.AddItem(starterBox); + + // Define snapshot points: (box count threshold, slot name, description) + var snapshotDefs = new (int boxes, string slot, string desc)[] + { + (10, "snapshot_1", "Very early game"), + (30, "snapshot_2", "First meta unlocks"), + (75, "snapshot_3", "Several UI panels"), + (150, "snapshot_4", "Adventures + cosmetics"), + (300, "snapshot_5", "Crafting + workshops"), + (500, "snapshot_6", "Most features"), + (750, "snapshot_7", "Near completion"), + (1000, "snapshot_8", "Endgame"), + (2000, "snapshot_9", "Post-endgame"), + }; + + int nextSnapshotIdx = 0; + int totalBoxesOpened = 0; + int maxTarget = snapshotDefs[^1].boxes; + + var saveManager = new Persistence.SaveManager(); + var report = new System.Text.StringBuilder(); + report.AppendLine(); + report.AppendLine("╔════════════════════════════════════════════════════════════╗"); + report.AppendLine("║ SAVE SNAPSHOT GENERATOR ║"); + report.AppendLine("╠════════════════════════════════════════════════════════════╣"); + + for (int i = 0; i < 15_000 && totalBoxesOpened < maxTarget; i++) + { + var box = state.Inventory + .FirstOrDefault(item => registry.IsBox(item.DefinitionId)); + if (box is null) break; + + var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; + simulation.ProcessAction(action, state); + + // Fast-forward crafting + bool keepCrafting; + do + { + foreach (var job in state.ActiveCraftingJobs + .Where(j => j.Status == CraftingJobStatus.InProgress)) + job.StartedAt = DateTime.UtcNow.AddHours(-1); + craftingEngine.TickJobs(state); + craftingEngine.CollectCompleted(state, registry); + var cascade = craftingEngine.AutoCraftCheck(state, registry); + keepCrafting = cascade.OfType().Any(); + } while (keepCrafting); + + totalBoxesOpened++; + + // Check if we've hit a snapshot point + if (nextSnapshotIdx < snapshotDefs.Length && + totalBoxesOpened >= snapshotDefs[nextSnapshotIdx].boxes) + { + var (_, slot, desc) = snapshotDefs[nextSnapshotIdx]; + state.TotalBoxesOpened = totalBoxesOpened; + saveManager.Save(state, slot); + + int uiCount = state.UnlockedUIFeatures.Count; + int cosCount = state.UnlockedCosmetics.Count; + int advCount = state.UnlockedAdventures.Count; + int wsCount = state.UnlockedWorkstations.Count; + int invCount = state.Inventory.GroupBy(x => x.DefinitionId).Count(); + + report.AppendLine($"║ Ctrl+{nextSnapshotIdx + 1}: {slot,-14} box #{totalBoxesOpened,-5}" + + $" UI:{uiCount,2} Cos:{cosCount,2} Adv:{advCount,2} WS:{wsCount,2} Inv:{invCount,3} ║"); + report.AppendLine($"║ {desc,-56}║"); + + nextSnapshotIdx++; + } + } + + report.AppendLine("╠════════════════════════════════════════════════════════════╣"); + report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-38}║"); + report.AppendLine($"║ Snapshots generated: {nextSnapshotIdx,-37}║"); + report.AppendLine("╚════════════════════════════════════════════════════════════╝"); + + Console.WriteLine(report.ToString()); + + Assert.True(nextSnapshotIdx == snapshotDefs.Length, + $"Expected {snapshotDefs.Length} snapshots but only generated {nextSnapshotIdx}. " + + $"Game ran out of boxes at {totalBoxesOpened}."); + } + + // ── Playthrough Capture ───────────────────────────────────────────── + + /// + /// Simulates a game playthrough and captures the rendered output at each step. + /// Outputs a detailed report with the action taken and full panel rendering. + /// Run with: dotnet test --filter "PlaythroughCapture" --logger "console;verbosity=detailed" + /// + [Theory] + [InlineData(42, 15)] + [InlineData(777, 15)] + public void PlaythroughCapture(int seed, int steps) + { + var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); + var simulation = new GameSimulation(registry, new Random(seed)); + var craftingEngine = new CraftingEngine(); + var loc = new LocalizationManager(Locale.FR); + var state = GameState.Create("TestPlayer", Locale.FR); + + var starterBox = ItemInstance.Create("box_starter"); + state.AddItem(starterBox); + + var report = new System.Text.StringBuilder(); + report.AppendLine(); + report.AppendLine($"╔═══════════════════════════════════════════════════════════════╗"); + report.AppendLine($"║ PLAYTHROUGH CAPTURE — seed={seed}, {steps} steps ║"); + report.AppendLine($"╚═══════════════════════════════════════════════════════════════╝"); + + for (int step = 1; step <= steps; step++) + { + // ── Render current game state panels ── + var context = RenderContext.FromGameState(state); + string panelOutput = RenderGameStatePanels(state, context, registry, loc); + + report.AppendLine(); + report.AppendLine($"┌─── Step {step} ─── Boxes: {state.TotalBoxesOpened} | UI: {state.UnlockedUIFeatures.Count} | Inv: {state.Inventory.Count} ───"); + report.AppendLine(panelOutput); + + // ── Find a box to open ── + var box = state.Inventory.FirstOrDefault(item => registry.IsBox(item.DefinitionId)); + if (box is null) + { + report.AppendLine("│ ACTION: No boxes left — stopping."); + report.AppendLine("└───────────────────────────────────────"); + break; + } + + var boxDef = registry.GetBox(box.DefinitionId); + string boxName = boxDef is not null ? loc.Get(boxDef.NameKey) : box.DefinitionId; + report.AppendLine($"│ ACTION: Open box \"{boxName}\""); + + // ── Open the box ── + var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; + var events = simulation.ProcessAction(action, state); + + // ── Log events ── + foreach (var evt in events) + { + switch (evt) + { + case BoxOpenedEvent boe: + var bd = registry.GetBox(boe.BoxId); + string bn = bd is not null ? loc.Get(bd.NameKey) : boe.BoxId; + if (!boe.IsAutoOpen) + report.AppendLine($"│ EVENT: BoxOpened \"{bn}\""); + break; + case ItemReceivedEvent ire: + var idef = registry.GetItem(ire.Item.DefinitionId); + string iname = idef is not null ? loc.Get(idef.NameKey) : ire.Item.DefinitionId; + string rarity = (idef?.Rarity ?? ItemRarity.Common).ToString(); + report.AppendLine($"│ EVENT: Received [{rarity}] \"{iname}\""); + break; + case UIFeatureUnlockedEvent ufe: + context.Unlock(ufe.Feature); + report.AppendLine($"│ EVENT: ★ UI Feature unlocked: {ufe.Feature}"); + break; + case AdventureUnlockedEvent aue: + report.AppendLine($"│ EVENT: Adventure unlocked: {aue.Theme}"); + break; + case ResourceChangedEvent rce: + report.AppendLine($"│ EVENT: Resource {rce.Type}: {rce.OldValue} → {rce.NewValue}"); + break; + case CraftingStartedEvent cse: + report.AppendLine($"│ EVENT: Crafting started: {cse.RecipeId} at {cse.Workstation}"); + break; + case InteractionTriggeredEvent ite: + report.AppendLine($"│ EVENT: Interaction: {ite.DescriptionKey}"); + break; + case MessageEvent me: + report.AppendLine($"│ EVENT: Message: {me.MessageKey}"); + break; + } + } + + // ── Fast-forward crafting ── + bool keepCrafting; + do + { + foreach (var job in state.ActiveCraftingJobs + .Where(j => j.Status == CraftingJobStatus.InProgress)) + job.StartedAt = DateTime.UtcNow.AddHours(-1); + craftingEngine.TickJobs(state); + var coll = craftingEngine.CollectCompleted(state, registry); + foreach (var ce in coll.OfType()) + { + var cd = registry.GetItem(ce.Item.DefinitionId); + string cn = cd is not null ? loc.Get(cd.NameKey) : ce.Item.DefinitionId; + report.AppendLine($"│ CRAFT: Collected \"{cn}\""); + } + var cascade = craftingEngine.AutoCraftCheck(state, registry); + keepCrafting = cascade.OfType().Any(); + } while (keepCrafting); + + state.TotalBoxesOpened++; + report.AppendLine("└───────────────────────────────────────"); + } + + // ── Final state rendering ── + report.AppendLine(); + report.AppendLine("╔═══════════════════════════════════════════════════════════════╗"); + report.AppendLine($"║ FINAL STATE — {state.TotalBoxesOpened} boxes opened ║"); + report.AppendLine("╚═══════════════════════════════════════════════════════════════╝"); + var finalCtx = RenderContext.FromGameState(state); + report.AppendLine(RenderGameStatePanels(state, finalCtx, registry, loc)); + + Console.WriteLine(report.ToString()); + Assert.True(true); + } + + /// + /// Renders game state panels to a plain string (strips ANSI for readability). + /// + private static string RenderGameStatePanels( + GameState state, RenderContext ctx, + ContentRegistry registry, LocalizationManager loc) + { + var sb = new System.Text.StringBuilder(); + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + Ansi = AnsiSupport.No, // plain text, no ANSI escapes + ColorSystem = ColorSystemSupport.NoColors + }); + console.Profile.Width = SpectreRenderer.RefWidth; + + // Portrait + if (ctx.HasPortraitPanel) + { + console.Write(PortraitPanel.Render(state.Appearance)); + } + + // Stats + if (ctx.HasStatsPanel) + { + console.Write(StatsPanel.Render(state, loc)); + } + + // Resources + if (ctx.HasResourcePanel) + { + console.Write(ResourcePanel.Render(state)); + } + + // Inventory (compact) + if (ctx.HasInventoryPanel) + { + console.Write(InventoryPanel.Render(state, registry, loc, compact: true)); + } + + // Crafting + if (ctx.HasCraftingPanel) + { + console.Write(CraftingPanel.Render(state, registry, loc)); + } + + string output = writer.ToString(); + if (!string.IsNullOrWhiteSpace(output)) + { + foreach (var line in output.Split('\n')) + sb.AppendLine($"│ {line.TrimEnd()}"); + } + else + { + sb.AppendLine("│ (no panels unlocked yet)"); + } + return sb.ToString(); + } + // ── Helpers ────────────────────────────────────────────────────────── private static List LoadItems()