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/__init__.py +9 -0
- gitquest/__main__.py +8 -0
- gitquest/cli.py +149 -0
- gitquest/combat.py +91 -0
- gitquest/data/items.json +38 -0
- gitquest/data/monsters.json +106 -0
- gitquest/data/ranks.json +14 -0
- gitquest/engine.py +217 -0
- gitquest/entities.py +169 -0
- gitquest/flexcard.py +146 -0
- gitquest/generator.py +295 -0
- gitquest/git_parser.py +409 -0
- gitquest/renderer.py +256 -0
- gitquest-0.1.0.dist-info/METADATA +226 -0
- gitquest-0.1.0.dist-info/RECORD +19 -0
- gitquest-0.1.0.dist-info/WHEEL +5 -0
- gitquest-0.1.0.dist-info/entry_points.txt +2 -0
- gitquest-0.1.0.dist-info/licenses/LICENSE +21 -0
- gitquest-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|