gitquest 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitquest/git_parser.py ADDED
@@ -0,0 +1,409 @@
1
+ """Extract and normalize git commit history into plain dataclasses.
2
+
3
+ This module is the single source of truth for *reading* a repository. It tries
4
+ :mod:`git` (GitPython) first and transparently falls back to shelling out to the
5
+ ``git`` binary, so gitquest works even where GitPython is unavailable.
6
+
7
+ The output is a :class:`RepoData` object containing commits ordered oldest ->
8
+ newest (the direction the player travels: from the repo's birth toward HEAD).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ # --------------------------------------------------------------------------- #
20
+ # Errors
21
+ # --------------------------------------------------------------------------- #
22
+
23
+
24
+ class NotAGitRepoError(Exception):
25
+ """Raised when the target path is not inside a git repository."""
26
+
27
+
28
+ class EmptyHistoryError(Exception):
29
+ """Raised when a repository has no commits to play through."""
30
+
31
+
32
+ # --------------------------------------------------------------------------- #
33
+ # Language detection
34
+ # --------------------------------------------------------------------------- #
35
+
36
+ # Map of lower-cased file extension -> human language/skill name.
37
+ EXTENSION_LANGUAGE: dict[str, str] = {
38
+ ".py": "Python",
39
+ ".pyi": "Python",
40
+ ".ipynb": "Python",
41
+ ".js": "JavaScript",
42
+ ".mjs": "JavaScript",
43
+ ".cjs": "JavaScript",
44
+ ".jsx": "JavaScript",
45
+ ".ts": "TypeScript",
46
+ ".tsx": "TypeScript",
47
+ ".rs": "Rust",
48
+ ".go": "Go",
49
+ ".java": "Java",
50
+ ".kt": "Kotlin",
51
+ ".kts": "Kotlin",
52
+ ".c": "C",
53
+ ".h": "C",
54
+ ".cpp": "C++",
55
+ ".cc": "C++",
56
+ ".cxx": "C++",
57
+ ".hpp": "C++",
58
+ ".cs": "C#",
59
+ ".rb": "Ruby",
60
+ ".php": "PHP",
61
+ ".swift": "Swift",
62
+ ".scala": "Scala",
63
+ ".sh": "Shell",
64
+ ".bash": "Shell",
65
+ ".zsh": "Shell",
66
+ ".ps1": "PowerShell",
67
+ ".lua": "Lua",
68
+ ".r": "R",
69
+ ".dart": "Dart",
70
+ ".ex": "Elixir",
71
+ ".exs": "Elixir",
72
+ ".clj": "Clojure",
73
+ ".hs": "Haskell",
74
+ ".ml": "OCaml",
75
+ ".sql": "SQL",
76
+ ".html": "HTML",
77
+ ".htm": "HTML",
78
+ ".css": "CSS",
79
+ ".scss": "CSS",
80
+ ".sass": "CSS",
81
+ ".less": "CSS",
82
+ ".vue": "Vue",
83
+ ".svelte": "Svelte",
84
+ ".md": "Docs",
85
+ ".rst": "Docs",
86
+ ".txt": "Docs",
87
+ ".json": "Config",
88
+ ".yaml": "Config",
89
+ ".yml": "Config",
90
+ ".toml": "Config",
91
+ ".ini": "Config",
92
+ ".cfg": "Config",
93
+ ".xml": "Config",
94
+ }
95
+
96
+
97
+ def language_for_file(filename: str) -> str:
98
+ """Return the language/skill name for a filename based on its extension."""
99
+ ext = Path(filename).suffix.lower()
100
+ return EXTENSION_LANGUAGE.get(ext, "Misc")
101
+
102
+
103
+ # --------------------------------------------------------------------------- #
104
+ # Data model
105
+ # --------------------------------------------------------------------------- #
106
+
107
+
108
+ @dataclass
109
+ class CommitInfo:
110
+ """Normalized representation of a single commit."""
111
+
112
+ sha: str
113
+ short_sha: str
114
+ author_name: str
115
+ author_email: str
116
+ timestamp: datetime
117
+ summary: str # first line of the commit message
118
+ message: str # full commit message
119
+ insertions: int
120
+ deletions: int
121
+ files: list[str] = field(default_factory=list)
122
+ is_merge: bool = False
123
+ parent_count: int = 1
124
+
125
+ @property
126
+ def files_changed(self) -> int:
127
+ return len(self.files)
128
+
129
+ @property
130
+ def churn(self) -> int:
131
+ """Total lines touched (added + removed)."""
132
+ return self.insertions + self.deletions
133
+
134
+ @property
135
+ def languages(self) -> dict[str, int]:
136
+ """Count of touched files per language for this commit."""
137
+ counts: dict[str, int] = {}
138
+ for f in self.files:
139
+ lang = language_for_file(f)
140
+ counts[lang] = counts.get(lang, 0) + 1
141
+ return counts
142
+
143
+
144
+ @dataclass
145
+ class RepoData:
146
+ """Everything gitquest needs to know about a repository."""
147
+
148
+ name: str
149
+ path: str
150
+ commits: list[CommitInfo] # oldest -> newest
151
+ sampled: bool = False
152
+ total_commits: int = 0
153
+
154
+ @property
155
+ def head(self) -> CommitInfo:
156
+ return self.commits[-1]
157
+
158
+ @property
159
+ def first(self) -> CommitInfo:
160
+ return self.commits[0]
161
+
162
+ @property
163
+ def authors(self) -> list[str]:
164
+ seen: dict[str, None] = {}
165
+ for c in self.commits:
166
+ seen.setdefault(c.author_name, None)
167
+ return list(seen)
168
+
169
+ @property
170
+ def total_insertions(self) -> int:
171
+ return sum(c.insertions for c in self.commits)
172
+
173
+ @property
174
+ def total_deletions(self) -> int:
175
+ return sum(c.deletions for c in self.commits)
176
+
177
+ @property
178
+ def merge_count(self) -> int:
179
+ return sum(1 for c in self.commits if c.is_merge)
180
+
181
+ @property
182
+ def language_totals(self) -> dict[str, int]:
183
+ totals: dict[str, int] = {}
184
+ for c in self.commits:
185
+ for lang, n in c.languages.items():
186
+ totals[lang] = totals.get(lang, 0) + n
187
+ return dict(sorted(totals.items(), key=lambda kv: kv[1], reverse=True))
188
+
189
+
190
+ # --------------------------------------------------------------------------- #
191
+ # Sampling
192
+ # --------------------------------------------------------------------------- #
193
+
194
+
195
+ def _sample_evenly(items: list, max_items: int) -> list:
196
+ """Deterministically down-sample a list while keeping first and last.
197
+
198
+ Picks evenly-spaced indices so the dungeon still spans the whole repo
199
+ history. Stable for a given (len, max_items) pair -> reproducible.
200
+ """
201
+ n = len(items)
202
+ if n <= max_items:
203
+ return items
204
+ # Always keep endpoints; evenly space the rest.
205
+ step = (n - 1) / (max_items - 1)
206
+ indices = sorted({round(i * step) for i in range(max_items)})
207
+ return [items[i] for i in indices]
208
+
209
+
210
+ # --------------------------------------------------------------------------- #
211
+ # GitPython backend
212
+ # --------------------------------------------------------------------------- #
213
+
214
+
215
+ def _parse_with_gitpython(path: str, max_commits: int) -> RepoData | None:
216
+ """Parse using GitPython. Returns None if GitPython is unavailable."""
217
+ try:
218
+ import git # type: ignore
219
+ except ImportError:
220
+ return None
221
+
222
+ try:
223
+ repo = git.Repo(path, search_parent_directories=True)
224
+ except git.exc.InvalidGitRepositoryError as exc: # type: ignore[attr-defined]
225
+ raise NotAGitRepoError(f"{path} is not a git repository") from exc
226
+ except git.exc.NoSuchPathError as exc: # type: ignore[attr-defined]
227
+ raise NotAGitRepoError(f"{path} does not exist") from exc
228
+
229
+ try:
230
+ raw = list(repo.iter_commits()) # newest -> oldest
231
+ except Exception as exc: # bare repo / no HEAD / empty
232
+ raise EmptyHistoryError("repository has no commits") from exc
233
+
234
+ if not raw:
235
+ raise EmptyHistoryError("repository has no commits")
236
+
237
+ raw.reverse() # oldest -> newest
238
+ total = len(raw)
239
+ sampled_raw = _sample_evenly(raw, max_commits)
240
+
241
+ commits: list[CommitInfo] = []
242
+ for c in sampled_raw:
243
+ stats = c.stats
244
+ files = list(stats.files.keys())
245
+ commits.append(
246
+ CommitInfo(
247
+ sha=c.hexsha,
248
+ short_sha=c.hexsha[:7],
249
+ author_name=str(c.author.name or "Unknown"),
250
+ author_email=str(c.author.email or ""),
251
+ timestamp=datetime.fromtimestamp(c.committed_date, tz=timezone.utc),
252
+ summary=c.summary or "",
253
+ message=c.message or "",
254
+ insertions=int(stats.total.get("insertions", 0)),
255
+ deletions=int(stats.total.get("deletions", 0)),
256
+ files=files,
257
+ is_merge=len(c.parents) > 1,
258
+ parent_count=len(c.parents),
259
+ )
260
+ )
261
+
262
+ name = _repo_name(repo.working_dir or path)
263
+ return RepoData(
264
+ name=name,
265
+ path=os.path.abspath(repo.working_dir or path),
266
+ commits=commits,
267
+ sampled=total > max_commits,
268
+ total_commits=total,
269
+ )
270
+
271
+
272
+ # --------------------------------------------------------------------------- #
273
+ # git CLI fallback backend
274
+ # --------------------------------------------------------------------------- #
275
+
276
+ _RECORD_SEP = "\x1e" # between commits
277
+ _FIELD_SEP = "\x1f" # between header fields
278
+
279
+
280
+ def _run_git(path: str, args: list[str]) -> str:
281
+ result = subprocess.run(
282
+ ["git", "-C", path, *args],
283
+ capture_output=True,
284
+ text=True,
285
+ encoding="utf-8",
286
+ errors="replace",
287
+ )
288
+ if result.returncode != 0:
289
+ stderr = result.stderr.lower()
290
+ if "not a git repository" in stderr:
291
+ raise NotAGitRepoError(f"{path} is not a git repository")
292
+ raise RuntimeError(result.stderr.strip() or "git command failed")
293
+ return result.stdout
294
+
295
+
296
+ def _parse_with_cli(path: str, max_commits: int) -> RepoData:
297
+ """Parse history by shelling out to ``git log --numstat``."""
298
+ # Verify it is a repo first (clear error if git is missing entirely).
299
+ try:
300
+ _run_git(path, ["rev-parse", "--is-inside-work-tree"])
301
+ except FileNotFoundError as exc:
302
+ raise RuntimeError("git executable not found on PATH") from exc
303
+
304
+ fmt = _FIELD_SEP.join(["%H", "%an", "%ae", "%ct", "%P", "%s", "%b"])
305
+ log_format = f"{_RECORD_SEP}{fmt}"
306
+ out = _run_git(
307
+ path,
308
+ ["log", "--numstat", "--no-color", f"--pretty=format:{log_format}"],
309
+ )
310
+
311
+ records = [r for r in out.split(_RECORD_SEP) if r.strip()]
312
+ if not records:
313
+ raise EmptyHistoryError("repository has no commits")
314
+
315
+ commits: list[CommitInfo] = []
316
+ for rec in records:
317
+ header, _, body = rec.partition("\n")
318
+ parts = header.split(_FIELD_SEP)
319
+ if len(parts) < 7:
320
+ continue
321
+ sha, an, ae, ct, parents, summary, msg_body = parts[:7]
322
+
323
+ insertions = deletions = 0
324
+ files: list[str] = []
325
+ for line in body.splitlines():
326
+ line = line.strip()
327
+ if not line:
328
+ continue
329
+ cols = line.split("\t")
330
+ if len(cols) != 3:
331
+ continue
332
+ add, rem, fname = cols
333
+ insertions += 0 if add == "-" else int(add)
334
+ deletions += 0 if rem == "-" else int(rem)
335
+ files.append(fname)
336
+
337
+ full_message = summary if not msg_body.strip() else f"{summary}\n{msg_body}"
338
+ commits.append(
339
+ CommitInfo(
340
+ sha=sha,
341
+ short_sha=sha[:7],
342
+ author_name=an or "Unknown",
343
+ author_email=ae or "",
344
+ timestamp=datetime.fromtimestamp(int(ct), tz=timezone.utc),
345
+ summary=summary,
346
+ message=full_message,
347
+ insertions=insertions,
348
+ deletions=deletions,
349
+ files=files,
350
+ is_merge=len(parents.split()) > 1,
351
+ parent_count=len(parents.split()),
352
+ )
353
+ )
354
+
355
+ commits.reverse() # git log is newest-first -> make oldest-first
356
+ total = len(commits)
357
+ commits = _sample_evenly(commits, max_commits)
358
+
359
+ return RepoData(
360
+ name=_repo_name(path),
361
+ path=os.path.abspath(path),
362
+ commits=commits,
363
+ sampled=total > max_commits,
364
+ total_commits=total,
365
+ )
366
+
367
+
368
+ # --------------------------------------------------------------------------- #
369
+ # Public API
370
+ # --------------------------------------------------------------------------- #
371
+
372
+
373
+ def _repo_name(working_dir: str) -> str:
374
+ return os.path.basename(os.path.abspath(working_dir).rstrip(os.sep)) or "repo"
375
+
376
+
377
+ def parse_repo(path: str = ".", max_commits: int = 300) -> RepoData:
378
+ """Parse a repository into normalized :class:`RepoData`.
379
+
380
+ Args:
381
+ path: Path inside the target git repository.
382
+ max_commits: Upper bound on commits; larger histories are evenly
383
+ down-sampled so generation stays fast and deterministic.
384
+
385
+ Raises:
386
+ NotAGitRepoError: The path is not within a git repository.
387
+ EmptyHistoryError: The repository contains no commits.
388
+ """
389
+ if not os.path.exists(path):
390
+ raise NotAGitRepoError(f"{path} does not exist")
391
+
392
+ data = _parse_with_gitpython(path, max_commits)
393
+ if data is None:
394
+ data = _parse_with_cli(path, max_commits)
395
+ return data
396
+
397
+
398
+ if __name__ == "__main__": # pragma: no cover - manual smoke test
399
+ import sys
400
+
401
+ repo = parse_repo(sys.argv[1] if len(sys.argv) > 1 else ".")
402
+ print(f"Repo: {repo.name} ({len(repo.commits)} of {repo.total_commits} commits)")
403
+ print(f"Authors: {', '.join(repo.authors)}")
404
+ print(f"Languages: {repo.language_totals}")
405
+ print("First 5 rooms:")
406
+ for c in repo.commits[:5]:
407
+ tag = "MERGE" if c.is_merge else " "
408
+ print(f" [{tag}] {c.short_sha} +{c.insertions}/-{c.deletions} "
409
+ f"{c.files_changed}f {c.summary[:50]}")
gitquest/renderer.py ADDED
@@ -0,0 +1,256 @@
1
+ """All terminal rendering for gitquest, built on `rich`.
2
+
3
+ The engine talks to a :class:`Renderer` instead of calling ``print`` directly,
4
+ which keeps game logic free of presentation concerns and makes it easy to run
5
+ silent/auto playthroughs (the engine simply skips the renderer).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ from rich.align import Align
13
+ from rich.console import Console, Group
14
+ from rich.panel import Panel
15
+ from rich.prompt import Prompt
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+ from .entities import Dungeon, Item, Monster, Player
20
+
21
+ KIND_COLOR = {
22
+ "Bug": "red",
23
+ "Feature": "magenta",
24
+ "Refactor": "yellow",
25
+ "Test": "cyan",
26
+ "Docs": "blue",
27
+ "Build": "green",
28
+ "Imp": "white",
29
+ "Boss": "bright_red",
30
+ }
31
+
32
+
33
+ def hp_bar(current: int, maximum: int, width: int = 24, color: str = "green") -> Text:
34
+ """Render a coloured HP bar as rich Text."""
35
+ maximum = max(1, maximum)
36
+ current = max(0, min(current, maximum))
37
+ filled = int(round(width * current / maximum))
38
+ bar = Text()
39
+ bar.append("█" * filled, style=color)
40
+ bar.append("░" * (width - filled), style="grey37")
41
+ bar.append(f" {current}/{maximum}", style="bold")
42
+ return bar
43
+
44
+
45
+ class Renderer:
46
+ """Rich-powered presentation layer."""
47
+
48
+ def __init__(self, console: Console | None = None, fast: bool = False) -> None:
49
+ self.console = console or Console()
50
+ self.fast = fast
51
+
52
+ # -- timing helpers ---------------------------------------------------- #
53
+
54
+ def sleep(self, seconds: float) -> None:
55
+ if not self.fast:
56
+ time.sleep(seconds)
57
+
58
+ def pause(self, prompt: str = "Press [bold]Enter[/bold] to continue") -> None:
59
+ if self.fast:
60
+ return
61
+ self.console.print(f"[grey50]{prompt}…[/grey50]", end="")
62
+ try:
63
+ input()
64
+ except EOFError:
65
+ self.console.print()
66
+
67
+ def rule(self, text: str = "") -> None:
68
+ self.console.rule(text)
69
+
70
+ # -- intro / banners --------------------------------------------------- #
71
+
72
+ def banner(self, text: str) -> None:
73
+ self.console.print(Text(text, style="bold green"))
74
+
75
+ def intro(self, dungeon: Dungeon, player: Player) -> None:
76
+ body = Table.grid(padding=(0, 2))
77
+ body.add_column(style="bold cyan")
78
+ body.add_column()
79
+ body.add_row("Repository", dungeon.repo_name)
80
+ body.add_row("Seed", str(dungeon.seed))
81
+ body.add_row("Rooms (commits)", str(dungeon.size))
82
+ body.add_row("Merges (bosses)", str(dungeon.merge_count))
83
+ body.add_row("Allies (authors)", str(len(dungeon.authors)))
84
+ langs = ", ".join(list(dungeon.language_totals)[:5]) or "—"
85
+ body.add_row("Skill paths", langs)
86
+ if dungeon.sampled:
87
+ body.add_row(
88
+ "Note",
89
+ f"[yellow]Sampled {dungeon.size} of {dungeon.total_commits} commits[/yellow]",
90
+ )
91
+ self.console.print(
92
+ Panel(
93
+ body,
94
+ title="[bold]⚔️ GITQUEST ⚔️[/bold]",
95
+ subtitle=f"hero: [bold]{player.name}[/bold]",
96
+ border_style="green",
97
+ )
98
+ )
99
+ self.console.print(
100
+ "[grey62]Travel from the repo's birth toward HEAD. "
101
+ "Clear every room. Become legend.[/grey62]\n"
102
+ )
103
+
104
+ # -- rooms ------------------------------------------------------------- #
105
+
106
+ def room_header(self, room, index: int, total: int) -> None:
107
+ kind = "💀 MINI-BOSS ROOM" if room.is_boss else f"Room {index + 1}/{total}"
108
+ border = "bright_red" if room.is_boss else "cyan"
109
+ info = Table.grid(padding=(0, 1))
110
+ info.add_row(f"[bold]{room.title}[/bold]")
111
+ info.add_row(
112
+ f"[grey62]{room.short_sha} · +{room.insertions}/-{room.deletions} · "
113
+ f"{room.flavor}[/grey62]"
114
+ )
115
+ if room.npc_name:
116
+ info.add_row(f"[blue]🧙 You meet {room.npc_name}, an ally from this commit.[/blue]")
117
+ self.console.print(Panel(info, title=kind, border_style=border, expand=True))
118
+
119
+ def language_gain(self, languages: dict[str, int]) -> None:
120
+ if not languages:
121
+ return
122
+ parts = [f"+{n} {lang}" for lang, n in languages.items()]
123
+ self.console.print(f"[green]🧠 Skill XP: {', '.join(parts)}[/green]")
124
+
125
+ # -- combat ------------------------------------------------------------ #
126
+
127
+ def encounter(self, monster: Monster) -> None:
128
+ color = KIND_COLOR.get(monster.kind, "white")
129
+ title = f"[{color}]A {monster.kind} appears![/{color}]"
130
+ art = Text(monster.art, style=f"bold {color}")
131
+ name = Text(monster.name, style=f"bold {color}")
132
+ stats = Text(
133
+ f"HP {monster.hp} · ATK {monster.attack} · DEF {monster.defense}",
134
+ style="grey70",
135
+ )
136
+ self.console.print(
137
+ Panel(
138
+ Align.center(Group(art, name, stats)),
139
+ title=title,
140
+ border_style=color,
141
+ )
142
+ )
143
+
144
+ def combat_status(self, player: Player, monster: Monster) -> None:
145
+ color = KIND_COLOR.get(monster.kind, "white")
146
+ grid = Table.grid(padding=(0, 2))
147
+ grid.add_column(justify="right", style="bold")
148
+ grid.add_column()
149
+ grid.add_row(player.name, hp_bar(player.hp, player.max_hp, color="green"))
150
+ grid.add_row(monster.name, hp_bar(monster.hp, monster.max_hp, color=color))
151
+ self.console.print(grid)
152
+
153
+ def prompt_action(self, player: Player) -> str:
154
+ choices = {"a": "attack", "d": "defend", "i": "item", "r": "run"}
155
+ n_potions = len(player.potions)
156
+ hint = (
157
+ f"[bold green]A[/bold green]ttack "
158
+ f"[bold yellow]D[/bold yellow]efend "
159
+ f"[bold cyan]I[/bold cyan]tem({n_potions}) "
160
+ f"[bold red]R[/bold red]un"
161
+ )
162
+ self.console.print(hint)
163
+ ans = Prompt.ask("Your move", choices=list(choices), default="a", show_choices=False)
164
+ return choices[ans]
165
+
166
+ def choose_potion(self, player: Player) -> Item | None:
167
+ potions = player.potions
168
+ if not potions:
169
+ self.console.print("[grey62]No potions to drink![/grey62]")
170
+ return None
171
+ table = Table(title="Potions", border_style="cyan")
172
+ table.add_column("#", justify="right")
173
+ table.add_column("Item")
174
+ table.add_column("Effect")
175
+ for i, p in enumerate(potions, 1):
176
+ table.add_row(str(i), f"{p.icon} {p.name}", p.description)
177
+ self.console.print(table)
178
+ idx = Prompt.ask(
179
+ "Drink which? (0 to cancel)",
180
+ choices=[str(i) for i in range(0, len(potions) + 1)],
181
+ default="1",
182
+ )
183
+ i = int(idx)
184
+ return potions[i - 1] if 1 <= i <= len(potions) else None
185
+
186
+ def attack_line(self, attacker: str, target: str, damage: int, crit: bool, color: str) -> None:
187
+ crit_tag = " [bold yellow]CRIT![/bold yellow]" if crit else ""
188
+ self.console.print(
189
+ f"[{color}]» {attacker}[/{color}] hits [bold]{target}[/bold] for "
190
+ f"[bold]{damage}[/bold] damage.{crit_tag}"
191
+ )
192
+ self.sleep(0.35)
193
+
194
+ def defend_line(self, player: Player) -> None:
195
+ self.console.print(f"[yellow]» {player.name} raises a guard.[/yellow]")
196
+ self.sleep(0.25)
197
+
198
+ def heal_line(self, player: Player, item: Item, healed: int) -> None:
199
+ self.console.print(
200
+ f"[green]» {player.name} drinks {item.icon} {item.name}, +{healed} HP.[/green]"
201
+ )
202
+ self.sleep(0.3)
203
+
204
+ def run_line(self, success: bool) -> None:
205
+ if success:
206
+ self.console.print("[yellow]» You slip away from the fight![/yellow]")
207
+ else:
208
+ self.console.print("[red]» You can't escape![/red]")
209
+ self.sleep(0.3)
210
+
211
+ def monster_defeated(self, monster: Monster, xp: int, gold: int) -> None:
212
+ self.console.print(
213
+ f"[bold green]✔ {monster.name} defeated![/bold green] "
214
+ f"[grey70](+{xp} XP, +{gold} gold)[/grey70]"
215
+ )
216
+ self.sleep(0.3)
217
+
218
+ # -- loot / progression ------------------------------------------------ #
219
+
220
+ def loot(self, item: Item) -> None:
221
+ self.console.print(
222
+ f"[bold yellow]🎁 Looted {item.icon} {item.name}[/bold yellow] "
223
+ f"[grey70]— {item.description}[/grey70]"
224
+ )
225
+
226
+ def no_loot(self) -> None:
227
+ self.console.print("[grey50]No loot in this room.[/grey50]")
228
+
229
+ def level_up(self, player: Player, levels: int) -> None:
230
+ self.console.print(
231
+ Panel(
232
+ f"[bold yellow]LEVEL UP![/bold yellow] You are now level "
233
+ f"[bold]{player.level}[/bold]!\n"
234
+ f"Max HP {player.max_hp} · ATK {player.attack} · DEF {player.defense} "
235
+ f"(fully healed)",
236
+ border_style="yellow",
237
+ )
238
+ )
239
+ self.sleep(0.4)
240
+
241
+ def room_cleared(self, room) -> None:
242
+ self.console.print("[green]✓ Room cleared.[/green]\n")
243
+ self.sleep(0.2)
244
+
245
+ def info(self, text: str) -> None:
246
+ self.console.print(text)
247
+
248
+ def player_died(self, player: Player, room) -> None:
249
+ self.console.print(
250
+ Panel(
251
+ f"[bold red]☠ You fell in room {room.index + 1}: {room.title}[/bold red]\n"
252
+ f"[grey70]The dungeon claims another developer. Your legend ends here.[/grey70]",
253
+ title="GAME OVER",
254
+ border_style="red",
255
+ )
256
+ )