diff --git a/CLAUDE.md b/CLAUDE.md index 8601ad2..6fdf79b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,51 @@ 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. +## Render Capture Tests (Automated Visual Testing) + +These tests simulate gameplay and capture rendered panel output as plain text for review in the test console. They are the primary way to verify visual rendering without launching the game interactively. + +### PlaythroughCapture +Simulates N box openings and renders the full game state panels at each step: +``` +dotnet test --filter "PlaythroughCapture" --logger "console;verbosity=detailed" +``` +- Runs 2 seeds (42 and 777), 15 steps each +- Shows: portrait, stats, resources, inventory, crafting panels at each step +- Logs all events (items received, UI unlocks, crafting, adventures) +- Useful for checking progressive UI unlock rendering and panel composition + +### InventoryRenderCapture +Captures the interactive inventory panel rendering at 5 progression stages (box #20, 50, 100, 200, 500): +``` +dotnet test --filter "InventoryRenderCapture" --logger "console;verbosity=detailed" +``` +- Shows the inventory table with selection highlight (► indicator on first item) +- Renders the detail panel for each item type present at that stage: + - **Consumable**: effect (+N resource) and "Press Enter to use" prompt + - **LoreFragment**: full lore text displayed in italic + - **Cosmetic**: slot information (Hair, Eyes, Body, etc.) + - **Material**: material type and form (Raw, Ingot, Sheet, etc.) +- Useful after modifying `InventoryPanel.cs`, `RenderDetailPanel()`, or loot tables + +### Adding New Render Capture Tests + +To capture rendering for a new panel or UI element: +1. Use the helper pattern from `RenderGameStatePanels()` or `InventoryRenderCapture`: + ```csharp + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings { + Out = new AnsiConsoleOutput(writer), + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors + }); + console.Profile.Width = SpectreRenderer.RefWidth; + console.Write(MyPanel.Render(...)); + // writer.ToString() contains the plain-text rendering + ``` +2. Use `--logger "console;verbosity=detailed"` to see `Console.WriteLine()` output in the test runner +3. Tests should `Assert.True(true)` or use lightweight assertions — the goal is visual inspection of output + ## Terminal.Gui Mode Run with `--tui` for a tmux-like panel layout using Terminal.Gui: ``` diff --git a/content/data/boxes.json b/content/data/boxes.json index bb01b85..4969624 100644 --- a/content/data/boxes.json +++ b/content/data/boxes.json @@ -211,10 +211,10 @@ "guaranteedRolls": ["box_of_boxes"], "rollCount": 1, "entries": [ - {"itemDefinitionId": "meta_colors", "weight": 5}, - {"itemDefinitionId": "meta_autosave", "weight": 6}, - {"itemDefinitionId": "meta_arrows", "weight": 4}, - {"itemDefinitionId": "meta_animation", "weight": 4}, + {"itemDefinitionId": "meta_colors", "weight": 6}, + {"itemDefinitionId": "meta_stats", "weight": 4}, + {"itemDefinitionId": "meta_inventory", "weight": 4}, + {"itemDefinitionId": "meta_resources", "weight": 4}, {"itemDefinitionId": "box_meta_interface", "weight": 1} ] } @@ -229,11 +229,10 @@ "guaranteedRolls": ["box_of_boxes"], "rollCount": 1, "entries": [ - {"itemDefinitionId": "meta_inventory", "weight": 3}, - {"itemDefinitionId": "meta_resources", "weight": 3}, - {"itemDefinitionId": "meta_stats", "weight": 3}, + {"itemDefinitionId": "meta_portrait", "weight": 3}, + {"itemDefinitionId": "meta_arrows", "weight": 3}, {"itemDefinitionId": "meta_shortcuts", "weight": 3}, - {"itemDefinitionId": "meta_crafting", "weight": 3}, + {"itemDefinitionId": "meta_layout", "weight": 2}, {"itemDefinitionId": "box_meta_deep", "weight": 1} ] } @@ -248,10 +247,10 @@ "guaranteedRolls": ["box_of_boxes"], "rollCount": 1, "entries": [ - {"itemDefinitionId": "meta_extended_colors", "weight": 3}, - {"itemDefinitionId": "meta_chat", "weight": 3}, - {"itemDefinitionId": "meta_portrait", "weight": 2}, {"itemDefinitionId": "meta_completion", "weight": 3}, + {"itemDefinitionId": "meta_chat", "weight": 3}, + {"itemDefinitionId": "meta_crafting", "weight": 3}, + {"itemDefinitionId": "meta_extended_colors", "weight": 3}, {"itemDefinitionId": "box_meta_resources", "weight": 1} ] } @@ -288,7 +287,8 @@ "guaranteedRolls": ["box_of_boxes"], "rollCount": 1, "entries": [ - {"itemDefinitionId": "meta_layout", "weight": 2}, + {"itemDefinitionId": "meta_autosave", "weight": 2}, + {"itemDefinitionId": "meta_animation", "weight": 2}, {"itemDefinitionId": "meta_stat_strength", "weight": 1}, {"itemDefinitionId": "meta_stat_intelligence", "weight": 1}, {"itemDefinitionId": "meta_stat_luck", "weight": 1}, diff --git a/content/data/items.json b/content/data/items.json index 8a43d61..115760b 100644 --- a/content/data/items.json +++ b/content/data/items.json @@ -121,16 +121,16 @@ {"id": "mysterious_key", "nameKey": "item.mysterious_key", "descriptionKey": "item.mysterious_key.desc", "category": "Key", "rarity": "Rare", "tags": ["Key"]}, - {"id": "lore_1", "nameKey": "lore.fragment_1", "category": "LoreFragment", "rarity": "Uncommon", "tags": ["Lore"]}, - {"id": "lore_2", "nameKey": "lore.fragment_2", "category": "LoreFragment", "rarity": "Uncommon", "tags": ["Lore"]}, - {"id": "lore_3", "nameKey": "lore.fragment_3", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, - {"id": "lore_4", "nameKey": "lore.fragment_4", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, - {"id": "lore_5", "nameKey": "lore.fragment_5", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, - {"id": "lore_6", "nameKey": "lore.fragment_6", "category": "LoreFragment", "rarity": "Epic", "tags": ["Lore"]}, - {"id": "lore_7", "nameKey": "lore.fragment_7", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, - {"id": "lore_8", "nameKey": "lore.fragment_8", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, - {"id": "lore_9", "nameKey": "lore.fragment_9", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, - {"id": "lore_10", "nameKey": "lore.fragment_10", "category": "LoreFragment", "rarity": "Epic", "tags": ["Lore"]}, + {"id": "lore_1", "nameKey": "lore.name_1", "category": "LoreFragment", "rarity": "Uncommon", "tags": ["Lore"]}, + {"id": "lore_2", "nameKey": "lore.name_2", "category": "LoreFragment", "rarity": "Uncommon", "tags": ["Lore"]}, + {"id": "lore_3", "nameKey": "lore.name_3", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, + {"id": "lore_4", "nameKey": "lore.name_4", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, + {"id": "lore_5", "nameKey": "lore.name_5", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, + {"id": "lore_6", "nameKey": "lore.name_6", "category": "LoreFragment", "rarity": "Epic", "tags": ["Lore"]}, + {"id": "lore_7", "nameKey": "lore.name_7", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, + {"id": "lore_8", "nameKey": "lore.name_8", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, + {"id": "lore_9", "nameKey": "lore.name_9", "category": "LoreFragment", "rarity": "Rare", "tags": ["Lore"]}, + {"id": "lore_10", "nameKey": "lore.name_10", "category": "LoreFragment", "rarity": "Epic", "tags": ["Lore"]}, {"id": "material_wood_raw", "nameKey": "material.wood", "category": "Material", "rarity": "Common", "tags": ["Material"], "materialType": "Wood", "materialForm": "Raw"}, {"id": "material_wood_refined", "nameKey": "material.wood", "category": "Material", "rarity": "Common", "tags": ["Material"], "materialType": "Wood", "materialForm": "Refined"}, diff --git a/content/strings/en.json b/content/strings/en.json index c7e97a9..a0aa318 100644 --- a/content/strings/en.json +++ b/content/strings/en.json @@ -310,6 +310,19 @@ "lore.fragment_9": "Samuel wrote the original box-opening manual. Chapter 1: Open the box. Chapter 2: See Chapter 1.", "lore.fragment_10": "The Black Box contains a cat. Or doesn't. Until you open it, it both does and doesn't. The cat is also a box.", + "lore.name_1": "Fragment: Genesis", + "lore.name_2": "Fragment: The Order", + "lore.name_3": "Fragment: The Universe", + "lore.name_4": "Fragment: First Opening", + "lore.name_5": "Fragment: The Sound", + "lore.name_6": "Fragment: The Paradox", + "lore.name_7": "Fragment: The Strike", + "lore.name_8": "Fragment: The Machine", + "lore.name_9": "Fragment: The Manual", + "lore.name_10": "Fragment: Schrödinger", + + "log.title": "Event Log", + "cookie.1": "A box within a box is still a box.", "cookie.2": "ERROR: This cookie contains no fortune. Please try again.", "cookie.3": "You will open many boxes. This prediction has a 100% accuracy rate.", @@ -456,5 +469,14 @@ "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." + "destiny.thanks": "Thank you for playing Open The Box.", + + "inventory.controls": "↑↓ Navigate | Enter: Select | Esc/Q: Back", + "inventory.controls_use": "↑↓ Navigate | Enter: Use item | Esc/Q: Back", + "inventory.controls_lore": "↑↓ Navigate | Enter: Read | Esc/Q: Back", + "inventory.details": "Details", + "inventory.effect": "Effect", + "inventory.press_enter_use": "Press Enter to use", + "inventory.item_used": "{0} used!", + "inventory.cosmetic_slot": "Slot" } diff --git a/content/strings/fr.json b/content/strings/fr.json index 4e7566e..934da90 100644 --- a/content/strings/fr.json +++ b/content/strings/fr.json @@ -310,6 +310,19 @@ "lore.fragment_9": "Samuel a écrit le premier manuel d'ouverture de boîtes. Chapitre 1 : Ouvre la boîte. Chapitre 2 : Voir Chapitre 1.", "lore.fragment_10": "La Boîte Noire contient un chat. Ou pas. Jusqu'à ce que tu l'ouvres, elle en contient et n'en contient pas. Le chat est aussi une boîte.", + "lore.name_1": "Fragment : Genèse", + "lore.name_2": "Fragment : L'Ordre", + "lore.name_3": "Fragment : L'Univers", + "lore.name_4": "Fragment : Première Ouverture", + "lore.name_5": "Fragment : Le Son", + "lore.name_6": "Fragment : Le Paradoxe", + "lore.name_7": "Fragment : La Grève", + "lore.name_8": "Fragment : La Machine", + "lore.name_9": "Fragment : Le Manuel", + "lore.name_10": "Fragment : Schrödinger", + + "log.title": "Journal d'événements", + "cookie.1": "Une boîte dans une boîte reste une boîte.", "cookie.2": "ERREUR : Ce cookie ne contient aucune fortune. Réessayez.", "cookie.3": "Vous ouvrirez beaucoup de boîtes. Cette prédiction a un taux de précision de 100%.", @@ -456,5 +469,14 @@ "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." + "destiny.thanks": "Merci d'avoir joué à Open The Box.", + + "inventory.controls": "↑↓ Naviguer | Entrée : Sélectionner | Échap/Q : Retour", + "inventory.controls_use": "↑↓ Naviguer | Entrée : Utiliser | Échap/Q : Retour", + "inventory.controls_lore": "↑↓ Naviguer | Entrée : Lire | Échap/Q : Retour", + "inventory.details": "Détails", + "inventory.effect": "Effet", + "inventory.press_enter_use": "Appuyer sur Entrée pour utiliser", + "inventory.item_used": "{0} utilisé !", + "inventory.cosmetic_slot": "Emplacement" } diff --git a/proposals.md b/proposals.md index a92ac49..6b7e48d 100644 --- a/proposals.md +++ b/proposals.md @@ -4,132 +4,97 @@ Analyse basée sur la capture de 2 playthroughs (seeds 42 et 777, 15 étapes cha --- -## 1. Le vide initial : 30 boîtes sans aucun panel visuel +## 1. Le vide initial : 30 boîtes sans aucun panel visuel — DONE **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. +**Résolu** : +- **1B)** Portrait visible par défaut : une boîte nue `+------+` s'affiche dès le départ, les cosmétiques n'apparaissent qu'après le déverrouillage PortraitPanel. +- **1C)** Méta-déverrouillages réordonnés : TextColors/StatsPanel/InventoryPanel/ResourcePanel arrivent en premier (box_meta_basics), AutoSave/BoxAnimation repoussés en fin de progression (box_meta_mastery). Résultat : 1er panel visuel à la boîte #7-16 au lieu de #32-36. --- -## 2. Les noms de boîtes apparaissent comme IDs bruts +## 2. Les noms de boîtes apparaissent comme IDs bruts — ALREADY FIXED -**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. +**Constat** : Déjà corrigé dans une session précédente. `GetLocalizedName()` fait le fallback vers `registry.GetBox()`. --- -## 3. Cosmétiques reçus sans portrait pour les voir +## 3. Cosmétiques reçus sans portrait pour les voir — DONE -**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). +**Résolu** : +- **3A)** Portrait visible dès le départ (voir 1B). PortraitPanel arrive désormais à la boîte #32-36. +- **3C)** Auto-équipement : MetaEngine équipe automatiquement le premier cosmétique de chaque slot vide lors de la réception. Le joueur voit immédiatement le changement sur le portrait. --- -## 4. Ressources reçues mais invisibles +## 4. Ressources reçues mais invisibles — DONE -**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] -``` +**Résolu** : Quand le ResourcePanel n'est pas débloqué, un résumé inline des ressources visibles s'affiche après le loot : `[Santé 10/100 | Or 5]`. --- -## 5. Le layout "Full" arrive trop tard +## 5. Le layout "Full" arrive trop tard — DONE -**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. +**Résolu** : FullLayout déplacé de box_meta_mastery vers box_meta_interface. Résultat : FullLayout à la boîte #41-53 au lieu de #199. --- -## 6. Le panneau Chat est toujours vide +## 6. Le panneau Chat est toujours vide — DONE -**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). +**Résolu** : ChatPanel transformé en Journal d'événements. Affiche les N derniers événements (loot, déverrouillages, changements de ressources, aventures débloquées) au lieu de l'ancien dialogue vide. -## 7. Feedback d'événements trop éphémère +## 7. Feedback d'événements trop éphémère — DONE -**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. +**Résolu** : +- **7A)** Historique des événements dans le panel Log (remplace Chat). +- **7B)** Pas de Clear() en mode pré-FullLayout : le texte défile comme un terminal classique. --- -## 8. L'annonce FigletText de déverrouillage UI prend trop de place +## 8. L'annonce FigletText de déverrouillage UI prend trop de place — DONE -**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é ! ★ ║ -╚═══════════════════════════════════════╝ -``` +**Résolu** : FigletText remplacé par un Panel compact avec bordure double et étoiles : `★ Panneau d'inventaire ★`. Prend 3 lignes au lieu de 12. --- -## 9. Progression des cosmétiques : équipement auto +## 9. Progression des cosmétiques : équipement auto — DONE -**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. +**Résolu** : Voir 3C. Auto-équipement du premier cosmétique de chaque slot. --- -## 10. Lore fragments : noms trop longs +## 10. Lore fragments : noms trop longs — DONE -**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é. +**Résolu** : Les fragments de lore ont maintenant des noms courts ("Fragment : Genèse", "Fragment : L'Ordre", etc.) au lieu de phrases complètes. Les textes longs restent dans les clés `lore.fragment_N` pour affichage détaillé. --- -## 11. Consommables non consommables. +## 11. Consommables non consommables — DONE -**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ésolu** : +- **11A)** Inventaire interactif : les objets sont sélectionnables avec ↑↓/PgUp/PgDn, la ligne sélectionnée est mise en surbrillance avec un indicateur ►. +- **11B)** Panneau de détails : un cadre « Détails » s'affiche sous l'inventaire montrant le nom, la rareté, et les informations contextuelles de l'objet sélectionné (effet des consommables, texte de lore complet, slot cosmétique, type de matériau). +- **Consommables** : Appuyer sur Entrée utilise le consommable via `UseItemAction` → `ResourceEngine.ProcessConsumable()`. L'item est consommé et la ressource mise à jour. +- **Fragments de lore** : Appuyer sur Entrée affiche le texte complet dans un panneau dédié avec bordure double. --- -## Résumé des priorités +## Résumé -| # | 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 | +| # | Proposition | Statut | +|---|------------|--------| +| 1B | Portrait visible par défaut | ✅ DONE | +| 1C | Réordonner les méta-déverrouillages | ✅ DONE | +| 2 | Noms de boîtes localisés dans le loot | ✅ ALREADY FIXED | +| 3C | Auto-équiper le 1er cosmétique | ✅ DONE | +| 4 | Résumé ressources inline | ✅ DONE | +| 5 | FullLayout plus tôt | ✅ DONE | +| 6A | Chat comme log d'événements | ✅ DONE | +| 7A | Historique événements dans panel Log | ✅ DONE | +| 7B | Pas de Clear avant FullLayout | ✅ DONE | +| 8 | Annonce compacte des déverrouillages | ✅ DONE | +| 9 | Auto-équipement cosmétiques | ✅ DONE | +| 10 | Noms courts pour lore fragments | ✅ DONE | +| 11A | Inventaire interactif avec sélection | ✅ DONE | +| 11B | Panneau de détails contextuel | ✅ DONE | diff --git a/src/OpenTheBox/Core/GameState.cs b/src/OpenTheBox/Core/GameState.cs index ad790f7..b5bf2a7 100644 --- a/src/OpenTheBox/Core/GameState.cs +++ b/src/OpenTheBox/Core/GameState.cs @@ -36,6 +36,12 @@ public sealed class GameState public HashSet AvailableTextColors { get; set; } = []; public List ActiveCraftingJobs { get; set; } = []; + /// + /// In-memory event log shown in the ChatPanel (not persisted to save). + /// + [System.Text.Json.Serialization.JsonIgnore] + public List EventLog { get; set; } = []; + /// /// Returns the current value of a resource, or 0 if the resource is not tracked. /// diff --git a/src/OpenTheBox/Program.cs b/src/OpenTheBox/Program.cs index e733d7f..afa037c 100644 --- a/src/OpenTheBox/Program.cs +++ b/src/OpenTheBox/Program.cs @@ -358,7 +358,11 @@ public static class Program // Tick crafting jobs (InProgress → Completed) TickCraftingJobs(); - _renderer.Clear(); + // Proposal 7B: Only clear screen when FullLayout is active; + // before that, let text scroll like a classic terminal + if (_renderContext.HasFullLayout) + _renderer.Clear(); + UpdateCompletionPercent(); _renderer.ShowGameState(_state, _renderContext); @@ -496,8 +500,9 @@ public static class Program case UIFeatureUnlockedEvent uiEvt: _renderContext.Unlock(uiEvt.Feature); RefreshRenderer(); - _renderer.ShowUIFeatureUnlocked( - _loc.Get(GetUIFeatureLocKey(uiEvt.Feature))); + var featureLabel = _loc.Get(GetUIFeatureLocKey(uiEvt.Feature)); + _renderer.ShowUIFeatureUnlocked(featureLabel); + AddEventLog($"★ {featureLabel}"); _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); break; @@ -508,6 +513,7 @@ public static class Program case ResourceChangedEvent resEvt: var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); _renderer.ShowMessage($"{resName}: {resEvt.OldValue} -> {resEvt.NewValue}"); + AddEventLog($"{resName}: {resEvt.OldValue} → {resEvt.NewValue}"); break; case MessageEvent msgEvt: @@ -523,7 +529,9 @@ public static class Program break; case AdventureUnlockedEvent advUnlockedEvt: - _renderer.ShowMessage(_loc.Get("adventure.unlocked", GetAdventureName(advUnlockedEvt.Theme))); + var advName = GetAdventureName(advUnlockedEvt.Theme); + _renderer.ShowMessage(_loc.Get("adventure.unlocked", advName)); + AddEventLog($"🗺 {advName}"); break; case AdventureStartedEvent advEvt: @@ -562,6 +570,20 @@ public static class Program if (allLoot.Count > 0) { _renderer.ShowLootReveal(allLoot); + + // Proposal 6A: Feed loot to the event log + foreach (var (name, rarity, _) in allLoot) + AddEventLog($"+ {name} [{rarity}]"); + + // Proposal 4: Show inline resource summary when ResourcePanel is not unlocked + if (!_renderContext.HasResourcePanel && _state.Resources.Count > 0) + { + var resSummary = string.Join(" | ", _state.Resources + .Where(r => _state.VisibleResources.Contains(r.Key)) + .Select(r => $"{_loc.Get($"resource.{r.Key.ToString().ToLower()}")} {r.Value.Current}/{r.Value.Max}")); + if (resSummary.Length > 0) + _renderer.ShowMessage($" [{resSummary}]"); + } } _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); @@ -585,45 +607,130 @@ public static class Program return; } - int totalItems = InventoryPanel.GetItemCount(_state); - int maxOffset = Math.Max(0, totalItems - InventoryPanel.MaxVisibleRows); + var grouped = InventoryPanel.GetGroupedItems(_state, _registry); + int totalItems = grouped.Count; + int maxVisible = InventoryPanel.MaxVisibleRows; + int maxOffset = Math.Max(0, totalItems - maxVisible); int scrollOffset = 0; - bool scrollable = totalItems > InventoryPanel.MaxVisibleRows; + int selectedIndex = 0; while (true) { + // Recalculate grouped items (may change after using a consumable) + grouped = InventoryPanel.GetGroupedItems(_state, _registry); + totalItems = grouped.Count; + if (totalItems == 0) return; // inventory emptied + maxOffset = Math.Max(0, totalItems - maxVisible); + + // Clamp selection & scroll + selectedIndex = Math.Clamp(selectedIndex, 0, totalItems - 1); + scrollOffset = Math.Clamp(scrollOffset, 0, maxOffset); + + // Auto-scroll to keep selection visible + if (selectedIndex < scrollOffset) + scrollOffset = selectedIndex; + else if (selectedIndex >= scrollOffset + maxVisible) + scrollOffset = selectedIndex - maxVisible + 1; + _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"))}[/]"); + AnsiConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex)); + + // Detail panel for selected item + var selectedGroup = grouped[selectedIndex]; + var detailPanel = InventoryPanel.RenderDetailPanel(selectedGroup, _registry, _loc); + if (detailPanel is not null) + AnsiConsole.Write(detailPanel); + + // Controls hint + bool isUsable = selectedGroup.Category is ItemCategory.Consumable + && selectedGroup.Def?.ResourceType is not null; + bool isLore = selectedGroup.Category is ItemCategory.LoreFragment; + string controls = isUsable + ? _loc.Get("inventory.controls_use") + : isLore + ? _loc.Get("inventory.controls_lore") + : _loc.Get("inventory.controls"); + AnsiConsole.MarkupLine($"[dim]{Markup.Escape(controls)}[/]"); var key = Console.ReadKey(intercept: true); switch (key.Key) { case ConsoleKey.UpArrow: - scrollOffset = Math.Max(0, scrollOffset - 1); + selectedIndex = Math.Max(0, selectedIndex - 1); break; case ConsoleKey.DownArrow: - scrollOffset = Math.Min(maxOffset, scrollOffset + 1); + selectedIndex = Math.Min(totalItems - 1, selectedIndex + 1); break; case ConsoleKey.PageUp: - scrollOffset = Math.Max(0, scrollOffset - InventoryPanel.MaxVisibleRows); + selectedIndex = Math.Max(0, selectedIndex - maxVisible); break; case ConsoleKey.PageDown: - scrollOffset = Math.Min(maxOffset, scrollOffset + InventoryPanel.MaxVisibleRows); + selectedIndex = Math.Min(totalItems - 1, selectedIndex + maxVisible); + break; + case ConsoleKey.Enter: + HandleInventoryAction(selectedGroup); break; case ConsoleKey.Escape: case ConsoleKey.Q: return; - default: - if (!scrollable) return; // any key exits if not scrollable - break; } } } + private static void HandleInventoryAction(InventoryGroup item) + { + if (item.Def is null) return; + + switch (item.Category) + { + case ItemCategory.Consumable when item.Def.ResourceType.HasValue: + // Use the consumable through the simulation + var events = _simulation.ProcessAction( + new UseItemAction(item.FirstInstance.Id), _state); + foreach (var evt in events) + { + switch (evt) + { + case ResourceChangedEvent resEvt: + var resName = _loc.Get($"resource.{resEvt.Type.ToString().ToLower()}"); + string itemName = _loc.Get(item.Def.NameKey); + _renderer.ShowMessage(_loc.Get("inventory.item_used", itemName)); + _renderer.ShowMessage($"{resName}: {resEvt.OldValue} → {resEvt.NewValue}"); + AddEventLog($"🧪 {itemName} → {resName} {resEvt.OldValue}→{resEvt.NewValue}"); + break; + case MessageEvent msgEvt: + _renderer.ShowMessage(_loc.Get(msgEvt.MessageKey, msgEvt.Args ?? [])); + break; + } + } + _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); + break; + + case ItemCategory.LoreFragment: + // Display the full lore text in a dedicated panel + ShowLoreFragment(item); + break; + } + } + + private static void ShowLoreFragment(InventoryGroup item) + { + _renderer.Clear(); + string name = _loc.Get(item.Def!.NameKey); + string loreKey = $"lore.fragment_{item.DefId.Replace("lore_", "")}"; + string loreText = _loc.Get(loreKey); + + var panel = new Panel($"[italic]{Markup.Escape(loreText)}[/]") + .Header($"[bold yellow]📜 {Markup.Escape(name)}[/]") + .Border(BoxBorder.Double) + .BorderStyle(new Style(Color.Yellow)) + .Padding(2, 1) + .Expand(); + + AnsiConsole.Write(panel); + _renderer.WaitForKeyPress(_loc.Get("prompt.press_key")); + } + private static async Task StartAdventure() { var available = _state.UnlockedAdventures.ToList(); @@ -830,6 +937,18 @@ public static class Program return name.StartsWith("[MISSING:") ? theme.ToString() : name; } + private const int MaxEventLogEntries = 20; + + /// + /// Adds a message to the in-memory event log displayed in the ChatPanel. + /// + private static void AddEventLog(string message) + { + _state.EventLog.Add(message); + if (_state.EventLog.Count > MaxEventLogEntries) + _state.EventLog.RemoveAt(0); + } + private static string GetLocalizedName(string definitionId) { var itemDef = _registry.GetItem(definitionId); diff --git a/src/OpenTheBox/Rendering/Panels/ChatPanel.cs b/src/OpenTheBox/Rendering/Panels/ChatPanel.cs index 25e623a..67b23d3 100644 --- a/src/OpenTheBox/Rendering/Panels/ChatPanel.cs +++ b/src/OpenTheBox/Rendering/Panels/ChatPanel.cs @@ -1,61 +1,45 @@ +using OpenTheBox.Localization; using Spectre.Console; using Spectre.Console.Rendering; namespace OpenTheBox.Rendering.Panels; /// -/// Renders recent adventure dialogue messages in a framed panel. +/// Renders a compact event log panel showing recent game events. +/// Replaces the original dialogue-only chat panel with a general-purpose log. /// public static class ChatPanel { - private const int MaxVisibleMessages = 10; + private const int MaxVisibleMessages = 8; /// - /// Builds a renderable chat log from a list of dialogue messages. - /// Each message has an optional character name and text content. + /// Builds a renderable event log from a list of recent log messages. /// - public static IRenderable Render(List<(string? character, string text)> messages) + public static IRenderable Render(List logMessages, LocalizationManager? loc = null) { var rows = new List(); // Show only the most recent messages - var visible = messages.Count > MaxVisibleMessages - ? messages.Skip(messages.Count - MaxVisibleMessages).ToList() - : messages; + var visible = logMessages.Count > MaxVisibleMessages + ? logMessages.Skip(logMessages.Count - MaxVisibleMessages).ToList() + : logMessages; if (visible.Count == 0) { - rows.Add(new Markup("[dim]No dialogue yet.[/]")); + var emptyText = loc?.Get("craft.panel.empty") ?? "..."; + rows.Add(new Markup($"[dim]{Markup.Escape(emptyText)}[/]")); } else { - foreach (var (character, text) in visible) + foreach (var msg in visible) { - if (character is not null) - { - string color = CharacterColor(character); - rows.Add(new Markup($"[bold {color}]{Markup.Escape(character)}:[/] {Markup.Escape(text)}")); - } - else - { - rows.Add(new Markup($"[italic dim]{Markup.Escape(text)}[/]")); - } + rows.Add(new Markup($"[dim]{Markup.Escape(msg)}[/]")); } } + var title = loc?.Get("log.title") ?? "Log"; return new Panel(new Rows(rows)) - .Header("[bold aqua]Chat[/]") + .Header($"[bold aqua]{Markup.Escape(title)}[/]") .Border(BoxBorder.Rounded); } - - /// - /// Assigns a consistent color to a character name so each speaker is visually distinct. - /// - private static string CharacterColor(string character) - { - // Simple hash-based color selection for consistent per-character coloring - string[] colors = ["aqua", "yellow", "green", "magenta", "orange1", "cyan1", "deeppink1", "chartreuse1"]; - int hash = Math.Abs(character.GetHashCode()); - return colors[hash % colors.Length]; - } } diff --git a/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs b/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs index d2ca992..c0cae65 100644 --- a/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs +++ b/src/OpenTheBox/Rendering/Panels/InventoryPanel.cs @@ -8,8 +8,21 @@ using Spectre.Console.Rendering; namespace OpenTheBox.Rendering.Panels; +/// +/// Grouped inventory item for display and interaction purposes. +/// +public sealed record InventoryGroup( + string DefId, + ItemDefinition? Def, + int TotalQty, + ItemCategory Category, + ItemRarity Rarity, + /// The first ItemInstance in this group (used for UseItem actions). + ItemInstance FirstInstance); + /// /// Renders the player inventory as a Spectre grouped by category. +/// Supports an optional selected index for interactive item selection. /// public static class InventoryPanel { @@ -25,19 +38,40 @@ public static class InventoryPanel state.Inventory.GroupBy(i => i.DefinitionId).Count(); /// - /// Builds a renderable inventory table from the current game state. - /// Uses the registry to resolve category, rarity, and localized names. + /// Returns grouped inventory items sorted by category then definition id. /// + public static List GetGroupedItems(GameState state, ContentRegistry? registry = null) + { + return state.Inventory + .GroupBy(i => i.DefinitionId) + .Select(g => + { + var def = registry?.GetItem(g.Key); + return new InventoryGroup( + g.Key, + def, + g.Sum(i => i.Quantity), + def?.Category ?? ItemCategory.Box, + def?.Rarity ?? ItemRarity.Common, + g.First()); + }) + .OrderBy(x => x.Category) + .ThenBy(x => x.DefId) + .ToList(); + } + /// /// Builds a renderable inventory table. /// uses fewer visible rows for inline layout mode. + /// highlights the selected row (relative to full list, -1 = none). /// public static IRenderable Render( GameState state, ContentRegistry? registry = null, LocalizationManager? loc = null, int scrollOffset = 0, - bool compact = false) + bool compact = false, + int selectedIndex = -1) { int maxRows = compact ? CompactVisibleRows : MaxVisibleRows; @@ -48,31 +82,18 @@ public static class InventoryPanel .AddColumn(new TableColumn("[bold]Rarity[/]").Centered()) .AddColumn(new TableColumn("[bold]Qty[/]").RightAligned()); - // Group items by definition id, sorted by category then name - var grouped = state.Inventory - .GroupBy(i => i.DefinitionId) - .Select(g => - { - var def = registry?.GetItem(g.Key); - return new - { - DefId = g.Key, - Def = def, - TotalQty = g.Sum(i => i.Quantity), - Category = def?.Category ?? ItemCategory.Box, - Rarity = def?.Rarity ?? ItemRarity.Common - }; - }) - .OrderBy(x => x.Category) - .ThenBy(x => x.DefId) - .ToList(); + var grouped = GetGroupedItems(state, registry); int totalItems = grouped.Count; int clampedOffset = Math.Clamp(scrollOffset, 0, Math.Max(0, totalItems - maxRows)); var visible = grouped.Skip(clampedOffset).Take(maxRows).ToList(); - foreach (var item in visible) + for (int i = 0; i < visible.Count; i++) { + var item = visible[i]; + int globalIndex = clampedOffset + i; + bool isSelected = globalIndex == selectedIndex; + // Resolve localized name, truncate if needed string name = item.Def is not null && loc is not null ? loc.Get(item.Def.NameKey) @@ -84,11 +105,23 @@ public static class InventoryPanel string rarity = item.Rarity.ToString(); string color = RarityColor(item.Rarity); - table.AddRow( - $"[{color}]{Markup.Escape(name)}[/]", - $"[dim]{Markup.Escape(category)}[/]", - $"[{color}]{Markup.Escape(rarity)}[/]", - item.TotalQty.ToString()); + if (isSelected) + { + // Highlighted row: reverse video style with arrow indicator + table.AddRow( + $"[bold {color} on grey23]► {Markup.Escape(name)}[/]", + $"[bold on grey23]{Markup.Escape(category)}[/]", + $"[bold {color} on grey23]{Markup.Escape(rarity)}[/]", + $"[bold on grey23]{item.TotalQty}[/]"); + } + else + { + table.AddRow( + $"[{color}] {Markup.Escape(name)}[/]", + $"[dim]{Markup.Escape(category)}[/]", + $"[{color}]{Markup.Escape(rarity)}[/]", + item.TotalQty.ToString()); + } } if (totalItems == 0) @@ -116,6 +149,70 @@ public static class InventoryPanel .Border(BoxBorder.Rounded); } + /// + /// Builds a detail panel showing information about the selected item. + /// Shows description, effect, or lore text depending on item type. + /// + public static IRenderable? RenderDetailPanel( + InventoryGroup? item, + ContentRegistry? registry = null, + LocalizationManager? loc = null) + { + if (item?.Def is null) + return null; + + var rows = new List(); + string color = RarityColor(item.Rarity); + + // Item name + string name = loc is not null ? loc.Get(item.Def.NameKey) : item.DefId; + rows.Add(new Markup($"[bold {color}]{Markup.Escape(name)}[/] [dim]({Markup.Escape(item.Rarity.ToString())})[/]")); + + // Description if available + if (item.Def.DescriptionKey is not null && loc is not null) + { + string desc = loc.Get(item.Def.DescriptionKey); + rows.Add(new Markup($"[italic]{Markup.Escape(desc)}[/]")); + } + + // Category-specific details + switch (item.Category) + { + case ItemCategory.Consumable when item.Def.ResourceType.HasValue && item.Def.ResourceAmount.HasValue: + string resName = loc?.Get($"resource.{item.Def.ResourceType.Value.ToString().ToLower()}") ?? item.Def.ResourceType.Value.ToString(); + string sign = item.Def.ResourceAmount.Value >= 0 ? "+" : ""; + rows.Add(new Markup($"[green]{Markup.Escape(loc?.Get("inventory.effect") ?? "Effect")}:[/] {sign}{item.Def.ResourceAmount.Value} {Markup.Escape(resName)}")); + rows.Add(new Markup($"[dim yellow]{Markup.Escape(loc?.Get("inventory.press_enter_use") ?? "Press Enter to use")}[/]")); + break; + + case ItemCategory.LoreFragment: + // Show the full lore text + string loreKey = $"lore.fragment_{item.DefId.Replace("lore_", "")}"; + string loreText = loc?.Get(loreKey) ?? ""; + if (!string.IsNullOrEmpty(loreText)) + { + rows.Add(new Markup("")); + rows.Add(new Markup($"[italic dim]{Markup.Escape(loreText)}[/]")); + } + break; + + case ItemCategory.Cosmetic when item.Def.CosmeticSlot.HasValue: + string slotName = loc?.Get($"cosmetic.slot.{item.Def.CosmeticSlot.Value.ToString().ToLower()}") ?? item.Def.CosmeticSlot.Value.ToString(); + rows.Add(new Markup($"[dim]{Markup.Escape(loc?.Get("inventory.cosmetic_slot") ?? "Slot")}: {Markup.Escape(slotName)}[/]")); + break; + + case ItemCategory.Material when item.Def.MaterialType.HasValue: + rows.Add(new Markup($"[dim]{Markup.Escape(item.Def.MaterialType.Value.ToString())} ({Markup.Escape(item.Def.MaterialForm?.ToString() ?? "Raw")})[/]")); + break; + } + + string title = loc?.Get("inventory.details") ?? "Details"; + return new Panel(new Rows(rows)) + .Header($"[bold aqua]{Markup.Escape(title)}[/]") + .Border(BoxBorder.Rounded) + .Expand(); + } + private static string RarityColor(ItemRarity rarity) => rarity switch { ItemRarity.Common => "white", diff --git a/src/OpenTheBox/Rendering/Panels/PortraitPanel.cs b/src/OpenTheBox/Rendering/Panels/PortraitPanel.cs index 0bfd6a2..e904409 100644 --- a/src/OpenTheBox/Rendering/Panels/PortraitPanel.cs +++ b/src/OpenTheBox/Rendering/Panels/PortraitPanel.cs @@ -14,8 +14,26 @@ public static class PortraitPanel /// /// Builds a renderable ASCII art box portrait from the player's appearance settings. /// - public static IRenderable Render(PlayerAppearance appearance) + public static IRenderable Render(PlayerAppearance appearance, bool showCosmetics = true) { + if (!showCosmetics) + { + // Default bare box portrait before PortraitPanel is unlocked + string barePortrait = string.Join(Environment.NewLine, + "[white] [/]", + "[white] +------+ [/]", + "[white] | ? ? | [/]", + "[white] | | [/]", + "[white] +------+ [/]", + "[white] [/]", + "[white] [/]"); + + return new Panel(new Markup(barePortrait)) + .Header("[bold green]Portrait[/]") + .Border(BoxBorder.Rounded) + .Padding(1, 0); + } + string hair = GetHairArt(appearance.HairStyle); string eyes = GetEyeArt(appearance.EyeStyle); string body = GetBodyArt(appearance.BodyStyle); diff --git a/src/OpenTheBox/Rendering/SpectreRenderer.cs b/src/OpenTheBox/Rendering/SpectreRenderer.cs index dcc1058..1bc87a4 100644 --- a/src/OpenTheBox/Rendering/SpectreRenderer.cs +++ b/src/OpenTheBox/Rendering/SpectreRenderer.cs @@ -254,14 +254,17 @@ public sealed class SpectreRenderer : IRenderer { if (_context.HasColors) { - AnsiConsole.Write(new Rule($"[bold yellow]{Markup.Escape(_loc.Get("ui.feature_unlocked", featureName))}[/]").RuleStyle("yellow")); - AnsiConsole.Write(new FigletText(featureName).Color(Color.Yellow).Centered()); - AnsiConsole.Write(new Rule().RuleStyle("yellow")); + var panel = new Panel($"[bold yellow]★ {Markup.Escape(featureName)} ★[/]") + .Border(BoxBorder.Double) + .BorderStyle(new Style(Color.Yellow)) + .Padding(2, 0) + .Expand(); + AnsiConsole.Write(panel); } else { Console.WriteLine("========================================"); - Console.WriteLine($" {_loc.Get("ui.feature_unlocked", featureName)}"); + Console.WriteLine($" ★ {featureName} ★"); Console.WriteLine("========================================"); } } @@ -379,9 +382,7 @@ public sealed class SpectreRenderer : IRenderer topRow.AddColumn(new TableColumn("c3").NoWrap()); topRow.AddRow( - context.HasPortraitPanel - ? PortraitPanel.Render(state.Appearance) - : new Panel("[dim]???[/]").Header("Portrait").Expand(), + PortraitPanel.Render(state.Appearance, context.HasPortraitPanel), context.HasStatsPanel ? StatsPanel.Render(state, _loc) : new Panel("[dim]???[/]").Header("Stats").Expand(), @@ -408,7 +409,7 @@ public sealed class SpectreRenderer : IRenderer rightItems.Add(CraftingPanel.Render(state, _registry, _loc)); if (context.HasChatPanel) - rightItems.Add(ChatPanel.Render([])); + rightItems.Add(ChatPanel.Render(state.EventLog, _loc)); if (context.HasCompletionTracker) rightItems.Add(new Markup($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]")); @@ -429,7 +430,7 @@ public sealed class SpectreRenderer : IRenderer { // Row 1: group top panels side by side when more than one exists var topPanels = new List(); - if (context.HasPortraitPanel) topPanels.Add(PortraitPanel.Render(state.Appearance)); + topPanels.Add(PortraitPanel.Render(state.Appearance, context.HasPortraitPanel)); if (context.HasStatsPanel) topPanels.Add(StatsPanel.Render(state, _loc)); if (context.HasResourcePanel) topPanels.Add(ResourcePanel.Render(state)); @@ -452,7 +453,7 @@ public sealed class SpectreRenderer : IRenderer AnsiConsole.Write(CraftingPanel.Render(state, _registry, _loc)); if (context.HasChatPanel) - AnsiConsole.Write(ChatPanel.Render([])); + AnsiConsole.Write(ChatPanel.Render(state.EventLog, _loc)); if (context.HasCompletionTracker) AnsiConsole.Write(new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]").RuleStyle("cyan")); diff --git a/src/OpenTheBox/Simulation/MetaEngine.cs b/src/OpenTheBox/Simulation/MetaEngine.cs index 72def02..b8ae936 100644 --- a/src/OpenTheBox/Simulation/MetaEngine.cs +++ b/src/OpenTheBox/Simulation/MetaEngine.cs @@ -1,4 +1,5 @@ using OpenTheBox.Core; +using OpenTheBox.Core.Characters; using OpenTheBox.Core.Enums; using OpenTheBox.Core.Items; using OpenTheBox.Data; @@ -60,6 +61,23 @@ public class MetaEngine if (itemDef.CosmeticSlot.HasValue && itemDef.CosmeticValue is not null) { state.UnlockedCosmetics.Add(item.DefinitionId); + + // Auto-equip first cosmetic of each slot + bool slotIsEmpty = itemDef.CosmeticSlot.Value switch + { + CosmeticSlot.Hair => state.Appearance.HairStyle == HairStyle.None, + CosmeticSlot.Eyes => state.Appearance.EyeStyle == EyeStyle.None, + CosmeticSlot.Body => state.Appearance.BodyStyle == BodyStyle.Naked, + CosmeticSlot.Legs => state.Appearance.LegStyle == LegStyle.None, + CosmeticSlot.Arms => state.Appearance.ArmStyle == ArmStyle.None, + _ => false + }; + + if (slotIsEmpty) + { + state.Appearance.ApplyCosmetic(itemDef.CosmeticSlot.Value, itemDef.CosmeticValue); + events.Add(new CosmeticEquippedEvent(itemDef.CosmeticSlot.Value, itemDef.CosmeticValue)); + } } // Music melody: trigger music playback diff --git a/tests/OpenTheBox.Tests/RendererTests.cs b/tests/OpenTheBox.Tests/RendererTests.cs index f35c47a..db5e3f0 100644 --- a/tests/OpenTheBox.Tests/RendererTests.cs +++ b/tests/OpenTheBox.Tests/RendererTests.cs @@ -358,40 +358,32 @@ public class ChatPanelTests } [Fact] - public void Render_NarrationOnly_DoesNotThrow() + public void Render_SingleMessage_DoesNotThrow() { - var messages = new List<(string?, string)> { (null, "The door creaks open.") }; + var messages = new List { "The door creaks open." }; var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); Assert.NotEmpty(result); } [Fact] - public void Render_WithCharacter_DoesNotThrow() + public void Render_MultipleMessages_DoesNotThrow() { - var messages = new List<(string?, string)> { ("Guard", "Halt! Who goes there?") }; - var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); - Assert.NotEmpty(result); - } - - [Fact] - public void Render_MixedMessages_DoesNotThrow() - { - var messages = new List<(string?, string)> + var messages = new List { - (null, "You enter the cave."), - ("Dragon", "Who dares disturb my slumber?"), - (null, "The ground shakes."), - ("Dragon", "Prepare yourself!") + "+ Small Health Potion [Common]", + "★ Text Colors", + "Health: 0 → 10", + "🗺 Starbound" }; var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); Assert.NotEmpty(result); } [Fact] - public void Render_OverflowMessages_ShowsOnlyLast10() + public void Render_OverflowMessages_ShowsOnlyRecent() { var messages = Enumerable.Range(0, 15) - .Select(i => ((string?)$"NPC{i}", $"Message {i}")) + .Select(i => $"Event {i}") .ToList(); var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); Assert.NotEmpty(result); @@ -400,10 +392,10 @@ public class ChatPanelTests [Fact] public void Render_SpecialCharacters_DoesNotThrow() { - var messages = new List<(string?, string)> + var messages = new List { - ("[Boss]", "I am [ultimate] {boss}!"), - (null, "A [mysterious] voice echoes.") + "+ [Boss] item ", + "A [mysterious] voice echoes." }; var result = RenderHelper.RenderToString(ChatPanel.Render(messages)); Assert.NotEmpty(result); diff --git a/tests/OpenTheBox.Tests/UnitTest1.cs b/tests/OpenTheBox.Tests/UnitTest1.cs index 4bbdc58..0851d09 100644 --- a/tests/OpenTheBox.Tests/UnitTest1.cs +++ b/tests/OpenTheBox.Tests/UnitTest1.cs @@ -1552,6 +1552,124 @@ public class ContentValidationTests return sb.ToString(); } + // ── Inventory Render Capture ──────────────────────────────────────── + + /// + /// Captures the interactive inventory rendering at multiple progression stages. + /// Shows the inventory table with selection highlight and detail panels + /// for each item type (consumable, lore, cosmetic, material). + /// Run with: dotnet test --filter "InventoryRenderCapture" --logger "console;verbosity=detailed" + /// + [Fact] + public void InventoryRenderCapture() + { + var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath, RecipesPath); + var simulation = new GameSimulation(registry, new Random(42)); + var craftingEngine = new CraftingEngine(); + var loc = new LocalizationManager(Locale.FR); + var state = GameState.Create("InvPlayer", Locale.FR); + + var starterBox = ItemInstance.Create("box_starter"); + state.AddItem(starterBox); + + var report = new System.Text.StringBuilder(); + report.AppendLine(); + report.AppendLine("╔═══════════════════════════════════════════════════════════════════╗"); + report.AppendLine("║ INVENTORY RENDER CAPTURE — seed=42 ║"); + report.AppendLine("╚═══════════════════════════════════════════════════════════════════╝"); + + // Snapshot points for inventory renders + var capturePoints = new[] { 20, 50, 100, 200, 500 }; + int nextCapture = 0; + + var writer = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(writer), + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors + }); + console.Profile.Width = SpectreRenderer.RefWidth; + + for (int i = 0; i < 5000 && state.TotalBoxesOpened < 500; i++) + { + var box = state.Inventory.FirstOrDefault(item => registry.IsBox(item.DefinitionId)); + if (box is null) break; + + var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId }; + simulation.ProcessAction(action, state); + + // Fast-forward crafting + bool keepCrafting; + do + { + foreach (var job in state.ActiveCraftingJobs + .Where(j => j.Status == CraftingJobStatus.InProgress)) + job.StartedAt = DateTime.UtcNow.AddHours(-1); + craftingEngine.TickJobs(state); + craftingEngine.CollectCompleted(state, registry); + var cascade = craftingEngine.AutoCraftCheck(state, registry); + keepCrafting = cascade.OfType().Any(); + } while (keepCrafting); + + state.TotalBoxesOpened++; + + if (nextCapture < capturePoints.Length && + state.TotalBoxesOpened >= capturePoints[nextCapture]) + { + report.AppendLine(); + report.AppendLine($"┌─── Box #{state.TotalBoxesOpened} ── Inventory: {state.Inventory.GroupBy(x => x.DefinitionId).Count()} types ───"); + + var grouped = InventoryPanel.GetGroupedItems(state, registry); + + // Render inventory with first item selected + writer.GetStringBuilder().Clear(); + console.Write(InventoryPanel.Render(state, registry, loc, selectedIndex: 0)); + foreach (var line in writer.ToString().Split('\n')) + report.AppendLine($"│ {line.TrimEnd()}"); + + // Render detail panel for first item + if (grouped.Count > 0) + { + writer.GetStringBuilder().Clear(); + var detail = InventoryPanel.RenderDetailPanel(grouped[0], registry, loc); + if (detail is not null) + { + console.Write(detail); + foreach (var line in writer.ToString().Split('\n')) + report.AppendLine($"│ {line.TrimEnd()}"); + } + } + + // Show detail panel for specific item types if present + var interestingTypes = new[] { ItemCategory.Consumable, ItemCategory.LoreFragment, ItemCategory.Cosmetic, ItemCategory.Material }; + foreach (var cat in interestingTypes) + { + var sample = grouped.FirstOrDefault(g => g.Category == cat); + if (sample is not null && (grouped.Count == 0 || sample != grouped[0])) + { + int idx = grouped.IndexOf(sample); + report.AppendLine($"│ ── Detail for [{cat}]: {sample.DefId} ──"); + writer.GetStringBuilder().Clear(); + var detailAlt = InventoryPanel.RenderDetailPanel(sample, registry, loc); + if (detailAlt is not null) + { + console.Write(detailAlt); + foreach (var line in writer.ToString().Split('\n')) + report.AppendLine($"│ {line.TrimEnd()}"); + } + } + } + + report.AppendLine("└───────────────────────────────────────────────────────────────"); + nextCapture++; + } + } + + Console.WriteLine(report.ToString()); + Assert.True(nextCapture > 0, "No captures were taken — game may not have generated enough boxes."); + } + // ── Helpers ────────────────────────────────────────────────────────── private static List LoadItems()