Fix layout, portrait cosmetics, add TUI renderer, snapshots, and playthrough analysis

- Fix compact tmux-like layout (120×30 ref) with side-by-side panels
- Fix cosmetic portrait: PlayerAppearance now mutable with ApplyCosmetic()
- Add per-style intrinsic colors for all cosmetic types on portrait
- Add compact inventory mode (6 rows) for layout views
- Add Terminal.Gui renderer (--tui flag)
- Add save snapshot generation and --snapshot N CLI loading
- Add PlaythroughCapture test for automated output analysis
- Add destiny adventure French translation (intro.fr.lor)
- Remove FontStyle enum (fonts are collectibles only)
- Add proposals.md with 11 rendering improvement suggestions
- Update bugs.md (3 FIXMEs resolved)
This commit is contained in:
Samuel Bouchet 2026-03-13 21:37:09 +01:00
parent d69aa5b4a4
commit 82aba2b3eb
25 changed files with 2042 additions and 407 deletions

View file

@ -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

View file

@ -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/<runtime>/`. The target machine does **not** need .NET installed. Distribute the entire folder (exe + `content/`).

148
bugs.md
View file

@ -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 <marquee> 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<T>.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.

View file

@ -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.

View file

@ -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}
]
}
},

View file

@ -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"},

View file

@ -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."
}

View file

@ -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."
}

135
proposals.md Normal file
View file

@ -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 |

View file

@ -5,13 +5,44 @@ namespace OpenTheBox.Core.Characters;
/// <summary>
/// Visual appearance configuration for the player character.
/// </summary>
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; }
/// <summary>
/// Applies a cosmetic value to the appropriate slot on this appearance.
/// </summary>
public bool ApplyCosmetic(CosmeticSlot slot, string value)
{
switch (slot)
{
case CosmeticSlot.Hair:
if (Enum.TryParse<HairStyle>(value, ignoreCase: true, out var hair))
{ HairStyle = hair; return true; }
break;
case CosmeticSlot.Eyes:
if (Enum.TryParse<EyeStyle>(value, ignoreCase: true, out var eyes))
{ EyeStyle = eyes; return true; }
break;
case CosmeticSlot.Body:
if (Enum.TryParse<BodyStyle>(value, ignoreCase: true, out var body))
{ BodyStyle = body; return true; }
break;
case CosmeticSlot.Legs:
if (Enum.TryParse<LegStyle>(value, ignoreCase: true, out var legs))
{ LegStyle = legs; return true; }
break;
case CosmeticSlot.Arms:
if (Enum.TryParse<ArmStyle>(value, ignoreCase: true, out var arms))
{ ArmStyle = arms; return true; }
break;
}
return false;
}
}

View file

@ -1,27 +0,0 @@
namespace OpenTheBox.Core.Enums;
/// <summary>
/// Unlockable font styles for the CLI interface.
/// Found in Boite Meta. The default console font is always available;
/// additional fonts are unlocked as drops.
/// </summary>
public enum FontStyle
{
/// <summary>The default system console font. Always available.</summary>
Default,
/// <summary>Consolas monospaced font. Clean and technical.</summary>
Consolas,
/// <summary>Firetruc display font. Bold and playful.</summary>
Firetruc,
/// <summary>JetBrains Mono font. Developer-focused with ligatures.</summary>
Jetbrains,
/// <summary>Vercel One font. Modern and sleek.</summary>
VercelOne,
/// <summary>Toto Posted One font. Artistic and distinctive.</summary>
TotoPostedOne
}

View file

@ -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<FontStyle> AvailableFonts { get; set; } = [];
public HashSet<TextColor> AvailableTextColors { get; set; } = [];
public List<CraftingJob> ActiveCraftingJobs { get; set; } = [];
@ -103,7 +102,6 @@ public sealed class GameState
CurrentLocale = locale,
CreatedAt = DateTime.UtcNow,
TotalPlayTime = TimeSpan.Zero,
AvailableFonts = [],
AvailableTextColors = [],
ActiveCraftingJobs = []
};

View file

@ -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
);

View file

@ -16,6 +16,7 @@
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.54.0" />
<PackageReference Include="Terminal.Gui" Version="2.*" />
</ItemGroup>
<ItemGroup>

View file

@ -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,17 +30,57 @@ 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);
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)
{
LogError(ex);
@ -64,6 +106,24 @@ public static class Program
}
}
/// <summary>
/// In TUI mode, updates the existing renderer context instead of creating a new one.
/// In classic mode, creates a new renderer via the factory.
/// </summary>
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();
}
/// <summary>
/// Loads a snapshot save (snapshot_1 through snapshot_9) and enters the game loop directly.
/// Used with --snapshot N for quick visual testing of different progression stages.
/// </summary>
private static async Task LoadSnapshot(int slot)
{
string slotName = $"snapshot_{slot}";
var loaded = _saveManager.Load(slotName);
if (loaded == null)
{
_renderer = RendererFactory.Create(_renderContext, _loc, _registry);
_renderer.ShowError($"Snapshot save '{slotName}' not found. Run: dotnet test --filter GenerateSaveSnapshots");
_renderer.WaitForKeyPress("Press any key to exit...");
return;
}
_state = loaded;
_loc.Change(_state.CurrentLocale);
InitializeGame();
_renderer.ShowMessage($"Loaded snapshot {slot}: {_state.TotalBoxesOpened} boxes opened");
_renderer.ShowMessage($"UI features: {_state.UnlockedUIFeatures.Count}, " +
$"Adventures: {_state.UnlockedAdventures.Count}, " +
$"Cosmetics: {_state.UnlockedCosmetics.Count}, " +
$"Workshops: {_state.UnlockedWorkstations.Count}");
_renderer.WaitForKeyPress(_loc.Get("prompt.press_key"));
await GameLoop();
}
private static void InitializeGame()
{
_registry = ContentRegistry.LoadFromFiles(
@ -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 =>
if (_useTui)
{
var def = _registry.GetItem(g.Key);
var bDef = def is null ? _registry.GetBox(g.Key) : null;
var baseName = GetLocalizedName(g.Key);
return (
name: g.Count() > 1 ? $"{baseName} (x{g.Count()})" : baseName,
rarity: (def?.Rarity ?? bDef?.Rarity ?? ItemRarity.Common).ToString(),
category: (def?.Category ?? ItemCategory.Box).ToString()
);
})
.OrderBy(i => i.category)
.ThenBy(i => i.name)
.ToList();
_renderer.ShowLootReveal(grouped);
// 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;
}
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<string>
{
_loc.Get("destiny.continue"),
_loc.Get("destiny.quit")
};
int endChoice = _renderer.ShowSelection("", endOptions);
if (endChoice == 1)
{
_renderer.ShowMessage(_loc.Get("destiny.thanks"));
_renderer.WaitForKeyPress(_loc.Get("prompt.press_key"));
_running = false;
return;
}
}
}
catch (FileNotFoundException)
{
@ -655,17 +791,14 @@ public static class Program
var totalUIFeatures = Enum.GetValues<UIFeature>().Length;
var totalResources = Enum.GetValues<ResourceType>().Length;
var totalStats = Enum.GetValues<StatType>().Length;
var totalFonts = Enum.GetValues<FontStyle>().Length;
var total = totalCosmetics + totalAdventures + totalUIFeatures
+ totalResources + totalStats + totalFonts;
+ 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;
}

View file

@ -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;
/// </summary>
public static class InventoryPanel
{
private const int MaxNameWidth = 24;
public const int MaxVisibleRows = 15;
/// <summary>Compact row count for inline layout mode (fits in ~24-line terminals).</summary>
public const int CompactVisibleRows = 6;
/// <summary>
/// Returns the total number of distinct item groups in the inventory.
/// </summary>
public static int GetItemCount(GameState state) =>
state.Inventory.GroupBy(i => i.DefinitionId).Count();
/// <summary>
/// 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.
/// </summary>
public static IRenderable Render(GameState state, LocalizationManager? loc = null)
/// <summary>
/// Builds a renderable inventory table.
/// <paramref name="compact"/> uses fewer visible rows for inline layout mode.
/// </summary>
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);
foreach (var group in grouped)
.Select(g =>
{
string defId = group.Key;
int totalQty = group.Sum(i => i.Quantity);
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();
// Use localization if available, otherwise fall back to definition id
string name = loc is not null ? loc.Get(defId) : defId;
int totalItems = grouped.Count;
int clampedOffset = Math.Clamp(scrollOffset, 0, Math.Max(0, totalItems - maxRows));
var visible = grouped.Skip(clampedOffset).Take(maxRows).ToList();
// 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";
foreach (var item in visible)
{
// 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)] + "…";
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"
};
}

View file

@ -6,13 +6,13 @@ using Spectre.Console.Rendering;
namespace OpenTheBox.Rendering.Panels;
/// <summary>
/// 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.
/// </summary>
public static class PortraitPanel
{
/// <summary>
/// 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.
/// </summary>
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.None => " ",
HairStyle.Short => " ~~~~ ",
HairStyle.Long => " ~~~~~~ ",
HairStyle.Ponytail => " ~~~~\\ ",
HairStyle.Braided => " ///\\\\\\ ",
HairStyle.Cyberpunk => " /\\/\\/\\ ",
HairStyle.Fire => " ||| ",
HairStyle.StardustLegendary => " @@@@@ ",
_ => " ??? "
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 => " \\(===)/ ",
_ => " |[???]| "
};
// ── Leg styles ──────────────────────────────────────────────────────
private static string GetLegArt(LegStyle style) => style switch
{
LegStyle.None => " | | ",
LegStyle.Naked => " | | ",
LegStyle.Slip => " | | ",
LegStyle.Short => " | | ",
LegStyle.Panty => " | | ",
LegStyle.RocketBoots => " [| |] ",
LegStyle.PegLeg => " |/ ",
LegStyle.Tentacles => " {| |} ",
BodyStyle.Naked => " | | ",
BodyStyle.RegularTShirt => " | ==== | ",
BodyStyle.SexyTShirt => " | ~<<~ | ",
BodyStyle.Suit => " | #### | ",
BodyStyle.Armored => " |{####}| ",
BodyStyle.Robotic => " |[0110]| ",
_ => " | | "
};
// ── Arm 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 => " }{| |}{ ",
_ => " | | "
};
// ── Arm styles (sides of the box) ─────────────────────────────────
private static string GetArmArt(ArmStyle style) => style switch
{
ArmStyle.None => " / \\ ",
ArmStyle.None => " ",
ArmStyle.Short => " / \\ ",
ArmStyle.Regular => " _/ \\_ ",
ArmStyle.Long => " X X ",
ArmStyle.Mechanical => " / ~ ",
ArmStyle.Long => " __/ \\__ ",
ArmStyle.Mechanical => " ~/ \\~ ",
ArmStyle.Wings => " </ \\> ",
ArmStyle.ExtraPair => " </X X\\> ",
_ => " / \\ "
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
{

View file

@ -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<string>()
.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 ────────────────────────────────────────────────────
/// <summary>Reference terminal size: 120 columns × 30 rows.</summary>
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;
}
/// <summary>
@ -355,99 +366,95 @@ public sealed class SpectreRenderer : IRenderer
};
/// <summary>
/// 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
/// </summary>
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<IRenderable>();
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);
}
/// <summary>
/// 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.
/// </summary>
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<IRenderable>();
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"));
}
}
}

View file

@ -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;
/// <summary>
/// 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 <see cref="Application.Invoke"/>.
/// Blocking calls (ShowSelection, WaitForKeyPress, etc.) use
/// <see cref="ManualResetEventSlim"/> to synchronize.
/// </summary>
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<string> _chatMessages = [];
public TerminalGuiRenderer(RenderContext context, LocalizationManager loc, ContentRegistry? registry = null)
{
_context = context;
_loc = loc;
_registry = registry;
}
/// <summary>
/// Initializes Terminal.Gui and builds the layout. Must be called from the main thread.
/// </summary>
public void Initialize()
{
Application.Init();
_top = Application.Top;
BuildLayout();
}
/// <summary>
/// Runs the Terminal.Gui application loop. Blocks until Application.RequestStop().
/// </summary>
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<string> 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<string>(
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<string> 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<string>();
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<string>();
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<string>(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<string>();
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();
}
}

View file

@ -13,6 +13,19 @@ namespace OpenTheBox.Simulation;
/// </summary>
public class BoxEngine(ContentRegistry registry)
{
/// <summary>
/// The meta box upgrade chain. When all non-box items in a tier are already obtained,
/// the box automatically upgrades to the next tier.
/// </summary>
private static readonly string[] MetaBoxChain =
[
"box_meta_basics",
"box_meta_interface",
"box_meta_deep",
"box_meta_resources",
"box_meta_mastery"
];
/// <summary>
/// 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<GameEvent> Open(string boxDefId, GameState state, Random rng, bool isAutoOpen = false)
{
var events = new List<GameEvent>();
// 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
};
}
/// <summary>
/// Checks whether a loot entry's item has already been obtained by the player.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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];
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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)
{

View file

@ -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);
}

View file

@ -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<string>
@ -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 ────────────────────────────────────────
/// <summary>
/// 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"
/// </summary>
[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<CraftingStartedEvent>().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 ─────────────────────────────────────────────
/// <summary>
/// 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"
/// </summary>
[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<ItemReceivedEvent>())
{
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<CraftingStartedEvent>().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);
}
/// <summary>
/// Renders game state panels to a plain string (strips ANSI for readability).
/// </summary>
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<ItemDefinition> LoadItems()