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/entities.py ADDED
@@ -0,0 +1,169 @@
1
+ """Core game dataclasses: Player, Monster, Item, Room, Dungeon.
2
+
3
+ These are intentionally behaviour-light data containers. Generation lives in
4
+ :mod:`gitquest.generator`, combat in :mod:`gitquest.combat`, and orchestration
5
+ in :mod:`gitquest.engine`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+
13
+
14
+ class ItemKind(str, Enum):
15
+ """What an item does when used/collected."""
16
+
17
+ POTION = "potion" # restores HP
18
+ WEAPON = "weapon" # boosts attack
19
+ ARMOR = "armor" # boosts defense
20
+ RELIC = "relic" # boosts max HP / misc
21
+ TREASURE = "treasure" # pure gold/score
22
+
23
+
24
+ @dataclass
25
+ class Item:
26
+ """A piece of loot derived from a changed file."""
27
+
28
+ name: str
29
+ kind: ItemKind
30
+ value: int # magnitude of the effect (HP, atk, def, gold)
31
+ source_file: str = ""
32
+ description: str = ""
33
+
34
+ @property
35
+ def icon(self) -> str:
36
+ return {
37
+ ItemKind.POTION: "๐Ÿงช",
38
+ ItemKind.WEAPON: "โš”๏ธ",
39
+ ItemKind.ARMOR: "๐Ÿ›ก๏ธ",
40
+ ItemKind.RELIC: "๐Ÿ’",
41
+ ItemKind.TREASURE: "๐Ÿ’ฐ",
42
+ }[self.kind]
43
+
44
+
45
+ @dataclass
46
+ class Monster:
47
+ """An enemy spawned from a commit's message keywords + churn."""
48
+
49
+ name: str
50
+ kind: str # archetype, e.g. "Bug", "Feature", "Refactor"
51
+ hp: int
52
+ max_hp: int
53
+ attack: int
54
+ defense: int
55
+ xp_reward: int
56
+ gold_reward: int = 0
57
+ is_boss: bool = False
58
+ art: str = "(o_o)"
59
+
60
+ @property
61
+ def is_alive(self) -> bool:
62
+ return self.hp > 0
63
+
64
+
65
+ @dataclass
66
+ class Room:
67
+ """A single dungeon room generated from one commit."""
68
+
69
+ index: int # 0-based position in the dungeon
70
+ sha: str
71
+ short_sha: str
72
+ title: str # commit summary, trimmed
73
+ npc_name: str # commit author -> NPC/ally
74
+ size: str # "cramped" | "modest" | "large" | "cavernous"
75
+ is_boss: bool
76
+ monsters: list[Monster] = field(default_factory=list)
77
+ items: list[Item] = field(default_factory=list)
78
+ languages: dict[str, int] = field(default_factory=dict)
79
+ insertions: int = 0
80
+ deletions: int = 0
81
+ flavor: str = ""
82
+
83
+ @property
84
+ def cleared(self) -> bool:
85
+ return all(not m.is_alive for m in self.monsters)
86
+
87
+
88
+ @dataclass
89
+ class Player:
90
+ """The hero. Stats grow as the player clears rooms and gains XP."""
91
+
92
+ name: str
93
+ hp: int = 30
94
+ max_hp: int = 30
95
+ attack: int = 6
96
+ defense: int = 2
97
+ level: int = 1
98
+ xp: int = 0
99
+ xp_to_next: int = 20
100
+ gold: int = 0
101
+ skill_tree: dict[str, int] = field(default_factory=dict)
102
+ inventory: list[Item] = field(default_factory=list)
103
+ # career stats
104
+ kills: int = 0
105
+ bosses_slain: int = 0
106
+ rooms_cleared: int = 0
107
+ items_collected: int = 0
108
+ potions_used: int = 0
109
+ damage_dealt: int = 0
110
+ damage_taken: int = 0
111
+
112
+ @property
113
+ def is_alive(self) -> bool:
114
+ return self.hp > 0
115
+
116
+ def grant_language_xp(self, languages: dict[str, int]) -> None:
117
+ """Fill the skill tree based on languages coded in a room."""
118
+ for lang, n in languages.items():
119
+ self.skill_tree[lang] = self.skill_tree.get(lang, 0) + n
120
+
121
+ @property
122
+ def top_skill(self) -> str:
123
+ if not self.skill_tree:
124
+ return "Generalist"
125
+ return max(self.skill_tree.items(), key=lambda kv: kv[1])[0]
126
+
127
+ def add_xp(self, amount: int) -> int:
128
+ """Add XP and resolve level-ups. Returns the number of levels gained.
129
+
130
+ Each level raises max HP, attack and defense, fully heals the player,
131
+ and increases the XP required for the next level.
132
+ """
133
+ self.xp += max(0, amount)
134
+ levels = 0
135
+ while self.xp >= self.xp_to_next:
136
+ self.xp -= self.xp_to_next
137
+ self.level += 1
138
+ levels += 1
139
+ self.max_hp += 5
140
+ self.attack += 2
141
+ self.defense += 1
142
+ self.hp = self.max_hp # ding! full heal on level-up
143
+ self.xp_to_next = int(self.xp_to_next * 1.4) + 5
144
+ return levels
145
+
146
+ @property
147
+ def potions(self) -> list["Item"]:
148
+ return [i for i in self.inventory if i.kind == ItemKind.POTION]
149
+
150
+
151
+ @dataclass
152
+ class Dungeon:
153
+ """The full generated world plus the metadata used for the flex card."""
154
+
155
+ repo_name: str
156
+ repo_path: str
157
+ seed: int
158
+ rooms: list[Room]
159
+ authors: list[str]
160
+ language_totals: dict[str, int]
161
+ total_insertions: int
162
+ total_deletions: int
163
+ total_commits: int
164
+ sampled: bool
165
+ merge_count: int
166
+
167
+ @property
168
+ def size(self) -> int:
169
+ return len(self.rooms)
gitquest/flexcard.py ADDED
@@ -0,0 +1,146 @@
1
+ """Final shareable "flex card": ranking + ASCII career summary.
2
+
3
+ Designed to look good as a terminal screenshot for sharing. The career score is
4
+ deterministic for a given playthrough, and the rank is looked up from
5
+ ``data/ranks.json``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from functools import lru_cache
12
+ from importlib import resources
13
+
14
+ from rich.align import Align
15
+ from rich.console import Console, Group
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from .entities import Dungeon, Player
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def _ranks() -> list[dict]:
25
+ resource = resources.files("gitquest").joinpath("data", "ranks.json")
26
+ with resource.open("r", encoding="utf-8") as fh:
27
+ return json.load(fh)["ranks"]
28
+
29
+
30
+ def career_score(player: Player, dungeon: Dungeon, victorious: bool) -> int:
31
+ """Compute a single headline score from the playthrough.
32
+
33
+ Rewards rooms cleared, kills, bosses, level, gold, and skill breadth, with a
34
+ completion bonus for reaching HEAD alive.
35
+ """
36
+ score = 0
37
+ score += player.rooms_cleared * 12
38
+ score += player.kills * 8
39
+ score += player.bosses_slain * 60
40
+ score += (player.level - 1) * 40
41
+ score += player.gold
42
+ score += player.items_collected * 5
43
+ score += sum(player.skill_tree.values()) * 3
44
+ score += len(player.skill_tree) * 15 # breadth bonus
45
+ if victorious:
46
+ score += 200 + dungeon.size * 5
47
+ return int(score)
48
+
49
+
50
+ def rank_for_score(score: int) -> str:
51
+ """Return the highest rank title whose threshold the score meets."""
52
+ title = _ranks()[0]["title"]
53
+ for entry in _ranks():
54
+ if score >= entry["min_score"]:
55
+ title = entry["title"]
56
+ else:
57
+ break
58
+ return title
59
+
60
+
61
+ def _bar(label: str, value: int, max_value: int, color: str, width: int = 18) -> Text:
62
+ max_value = max(1, max_value)
63
+ filled = int(round(width * min(value, max_value) / max_value))
64
+ t = Text()
65
+ t.append(f"{label:<12}", style="bold")
66
+ t.append("โ–ˆ" * filled, style=color)
67
+ t.append("โ–‘" * (width - filled), style="grey37")
68
+ t.append(f" {value}", style="bold")
69
+ return t
70
+
71
+
72
+ def build_flexcard(
73
+ player: Player, dungeon: Dungeon, victorious: bool
74
+ ) -> Panel:
75
+ """Construct the rich Panel for the final flex card."""
76
+ score = career_score(player, dungeon, victorious)
77
+ rank = rank_for_score(score)
78
+
79
+ header = Text()
80
+ header.append("โš” GITQUEST CAREER CARD โš”\n", style="bold green")
81
+ header.append(f"{dungeon.repo_name}", style="bold white")
82
+ header.append(f" seed {dungeon.seed}\n", style="grey50")
83
+
84
+ rank_text = Text()
85
+ status = "VICTORIOUS" if victorious else "FELL IN BATTLE"
86
+ status_color = "bright_green" if victorious else "red"
87
+ rank_text.append(f"\n RANK: ", style="bold")
88
+ rank_text.append(f"{rank}\n", style="bold yellow")
89
+ rank_text.append(" SCORE: ", style="bold")
90
+ rank_text.append(f"{score}\n", style="bold cyan")
91
+ rank_text.append(" STATUS: ", style="bold")
92
+ rank_text.append(f"{status}\n", style=f"bold {status_color}")
93
+
94
+ # Career stats table.
95
+ stats = Table.grid(padding=(0, 3))
96
+ stats.add_column(style="cyan", justify="right")
97
+ stats.add_column(style="bold")
98
+ stats.add_column(style="cyan", justify="right")
99
+ stats.add_column(style="bold")
100
+ stats.add_row("Level", str(player.level), "Gold", str(player.gold))
101
+ stats.add_row("HP", f"{player.hp}/{player.max_hp}", "Attack", str(player.attack))
102
+ stats.add_row("Defense", str(player.defense), "Rooms", str(player.rooms_cleared))
103
+ stats.add_row("Kills", str(player.kills), "Bosses", str(player.bosses_slain))
104
+ stats.add_row("Items", str(player.items_collected), "Potions used", str(player.potions_used))
105
+ stats.add_row("Dmg dealt", str(player.damage_dealt), "Dmg taken", str(player.damage_taken))
106
+
107
+ # Skill tree bars (top languages).
108
+ skill_rows: list[Text] = []
109
+ if player.skill_tree:
110
+ top = sorted(player.skill_tree.items(), key=lambda kv: kv[1], reverse=True)[:6]
111
+ max_skill = max(v for _, v in top)
112
+ palette = ["green", "cyan", "magenta", "yellow", "blue", "red"]
113
+ for i, (lang, val) in enumerate(top):
114
+ skill_rows.append(_bar(lang, val, max_skill, palette[i % len(palette)]))
115
+ skill_group = Group(
116
+ Text("\n SKILL TREE", style="bold green"),
117
+ *[Align.left(r) for r in skill_rows],
118
+ ) if skill_rows else Text("")
119
+
120
+ top_skill = player.top_skill
121
+ footer = Text(
122
+ f"\n Specialization: {top_skill} ยท "
123
+ f"{len(player.skill_tree)} skill paths explored\n"
124
+ " generated by gitquest ๐Ÿ—ก๏ธ ยท share your run!",
125
+ style="grey62",
126
+ )
127
+
128
+ body = Group(
129
+ Align.center(header),
130
+ rank_text,
131
+ Text("\n CAREER STATS", style="bold green"),
132
+ stats,
133
+ skill_group,
134
+ footer,
135
+ )
136
+
137
+ border = "green" if victorious else "red"
138
+ return Panel(body, border_style=border, title="[bold]flex card[/bold]", padding=(1, 3))
139
+
140
+
141
+ def render_flexcard(
142
+ player: Player, dungeon: Dungeon, victorious: bool, console: Console | None = None
143
+ ) -> None:
144
+ """Print the flex card to the console."""
145
+ console = console or Console()
146
+ console.print(build_flexcard(player, dungeon, victorious))
gitquest/generator.py ADDED
@@ -0,0 +1,295 @@
1
+ """Deterministic dungeon generation: commit history -> playable world.
2
+
3
+ Generation is seeded from the repository (hash of the first commit's SHA) unless
4
+ the player overrides ``--seed``. Given the same repo and seed, the produced
5
+ :class:`~gitquest.entities.Dungeon` is byte-for-byte identical, which is what
6
+ makes gitquest runs reproducible and shareable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import random
14
+ from functools import lru_cache
15
+ from importlib import resources
16
+
17
+ from .entities import Dungeon, Item, ItemKind, Monster, Room
18
+ from .git_parser import CommitInfo, RepoData
19
+
20
+ # --------------------------------------------------------------------------- #
21
+ # Data loading
22
+ # --------------------------------------------------------------------------- #
23
+
24
+
25
+ @lru_cache(maxsize=None)
26
+ def _load_data(filename: str) -> dict:
27
+ """Load and cache a JSON file from the packaged ``data`` directory."""
28
+ resource = resources.files("gitquest").joinpath("data", filename)
29
+ with resource.open("r", encoding="utf-8") as fh:
30
+ return json.load(fh)
31
+
32
+
33
+ @lru_cache(maxsize=1)
34
+ def _keyword_index() -> list[tuple[str, str]]:
35
+ """Return (keyword, archetype) pairs sorted longest-first for matching."""
36
+ monsters = _load_data("monsters.json")
37
+ pairs: list[tuple[str, str]] = []
38
+ for archetype, spec in monsters.items():
39
+ for kw in spec.get("keywords", []):
40
+ pairs.append((kw.lower(), archetype))
41
+ pairs.sort(key=lambda p: len(p[0]), reverse=True)
42
+ return pairs
43
+
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # Seed
47
+ # --------------------------------------------------------------------------- #
48
+
49
+
50
+ def compute_seed(repo: RepoData, user_seed: int | None = None) -> int:
51
+ """Derive the generation seed from the repo, or honor a user override."""
52
+ if user_seed is not None:
53
+ return int(user_seed)
54
+ digest = hashlib.sha256(repo.first.sha.encode("utf-8")).hexdigest()
55
+ return int(digest[:16], 16)
56
+
57
+
58
+ # --------------------------------------------------------------------------- #
59
+ # Classification helpers
60
+ # --------------------------------------------------------------------------- #
61
+
62
+
63
+ def classify_archetype(commit: CommitInfo) -> str:
64
+ """Map a commit's message keywords to a monster archetype.
65
+
66
+ Merge commits always produce a boss. Otherwise the first matching keyword
67
+ (longest first) wins; unmatched commits spawn generic Imps.
68
+ """
69
+ if commit.is_merge:
70
+ return "Boss"
71
+ text = f"{commit.summary} {commit.message}".lower()
72
+ for keyword, archetype in _keyword_index():
73
+ if keyword and keyword in text:
74
+ return archetype
75
+ return "Imp"
76
+
77
+
78
+ def _room_size(churn: int) -> str:
79
+ if churn < 20:
80
+ return "cramped"
81
+ if churn < 100:
82
+ return "modest"
83
+ if churn < 400:
84
+ return "large"
85
+ return "cavernous"
86
+
87
+
88
+ _SIZE_FLAVOR = {
89
+ "cramped": "a tight crawlspace of tangled diffs",
90
+ "modest": "a modest chamber lit by terminal glow",
91
+ "large": "a sprawling hall echoing with merge conflicts",
92
+ "cavernous": "a cavernous vault humming with churn",
93
+ }
94
+
95
+
96
+ # --------------------------------------------------------------------------- #
97
+ # Monster + item factories
98
+ # --------------------------------------------------------------------------- #
99
+
100
+
101
+ def _make_monster(
102
+ rng: random.Random,
103
+ archetype: str,
104
+ churn: int,
105
+ depth: int,
106
+ depth_total: int,
107
+ is_boss: bool,
108
+ ) -> Monster:
109
+ spec = _load_data("monsters.json")[archetype]
110
+ name = rng.choice(spec["names"])
111
+ art = spec.get("art", "(o_o)")
112
+
113
+ capped = min(churn, 600)
114
+ depth_bonus = depth * 0.9
115
+ jitter = rng.uniform(0.85, 1.2)
116
+
117
+ hp = int((8 + capped / 10 + depth_bonus) * jitter) + 1
118
+ attack = int((3 + capped / 45 + depth * 0.30) * jitter) + 1
119
+ defense = int(capped / 150 + depth * 0.12)
120
+ xp_reward = int(9 + capped / 9 + depth * 1.6)
121
+ gold_reward = int(rng.randint(2, 8) + capped / 25 + depth * 0.5)
122
+
123
+ if is_boss:
124
+ hp = int(hp * 2.3) + 12
125
+ attack = int(attack * 1.4) + 2
126
+ defense = int(defense * 1.3) + 1
127
+ xp_reward = int(xp_reward * 3)
128
+ gold_reward = int(gold_reward * 3) + 10
129
+ name = f"{name} ๐Ÿ‘‘"
130
+
131
+ return Monster(
132
+ name=name,
133
+ kind=archetype,
134
+ hp=hp,
135
+ max_hp=hp,
136
+ attack=attack,
137
+ defense=defense,
138
+ xp_reward=xp_reward,
139
+ gold_reward=gold_reward,
140
+ is_boss=is_boss,
141
+ art=art,
142
+ )
143
+
144
+
145
+ # kind -> selection weight for normal loot rolls
146
+ _ITEM_WEIGHTS: list[tuple[ItemKind, float]] = [
147
+ (ItemKind.POTION, 0.34),
148
+ (ItemKind.WEAPON, 0.20),
149
+ (ItemKind.ARMOR, 0.20),
150
+ (ItemKind.RELIC, 0.11),
151
+ (ItemKind.TREASURE, 0.15),
152
+ ]
153
+
154
+
155
+ def _pick_item_kind(rng: random.Random) -> ItemKind:
156
+ roll = rng.random()
157
+ cumulative = 0.0
158
+ for kind, weight in _ITEM_WEIGHTS:
159
+ cumulative += weight
160
+ if roll <= cumulative:
161
+ return kind
162
+ return ItemKind.POTION
163
+
164
+
165
+ def _make_item(
166
+ rng: random.Random,
167
+ kind: ItemKind,
168
+ churn: int,
169
+ depth: int,
170
+ source_file: str,
171
+ ) -> Item:
172
+ names = _load_data("items.json")[kind.value]
173
+ name = rng.choice(names)
174
+ base = min(churn, 400) / 20 + depth * 0.5
175
+
176
+ if kind == ItemKind.POTION:
177
+ value = int(8 + base) + rng.randint(0, 6)
178
+ desc = f"Restores {value} HP."
179
+ elif kind == ItemKind.WEAPON:
180
+ value = max(1, int(1 + base / 6)) + rng.randint(0, 2)
181
+ desc = f"+{value} attack."
182
+ elif kind == ItemKind.ARMOR:
183
+ value = max(1, int(base / 8)) + rng.randint(0, 2)
184
+ desc = f"+{value} defense."
185
+ elif kind == ItemKind.RELIC:
186
+ value = max(2, int(2 + base / 5))
187
+ desc = f"+{value} max HP."
188
+ else: # TREASURE
189
+ value = int(5 + base * 2) + rng.randint(0, 10)
190
+ desc = f"Worth {value} gold."
191
+
192
+ return Item(name=name, kind=kind, value=value, source_file=source_file, description=desc)
193
+
194
+
195
+ # --------------------------------------------------------------------------- #
196
+ # Room + dungeon assembly
197
+ # --------------------------------------------------------------------------- #
198
+
199
+
200
+ def _build_room(
201
+ rng: random.Random,
202
+ commit: CommitInfo,
203
+ index: int,
204
+ depth_total: int,
205
+ ) -> Room:
206
+ churn = commit.churn
207
+ is_boss = commit.is_merge
208
+ archetype = classify_archetype(commit)
209
+ size = _room_size(churn)
210
+
211
+ # Monsters: one by default, a second guard in big non-boss rooms.
212
+ monsters: list[Monster] = []
213
+ if is_boss:
214
+ monsters.append(_make_monster(rng, "Boss", churn, index, depth_total, True))
215
+ else:
216
+ monsters.append(_make_monster(rng, archetype, churn, index, depth_total, False))
217
+ if churn >= 150 and rng.random() < 0.5:
218
+ monsters.append(
219
+ _make_monster(rng, archetype, churn // 2, index, depth_total, False)
220
+ )
221
+
222
+ # Loot from changed files (capped to keep inventory sane).
223
+ items: list[Item] = []
224
+ lootable = commit.files[:4]
225
+ for fname in lootable:
226
+ if rng.random() < 0.7:
227
+ kind = _pick_item_kind(rng)
228
+ items.append(_make_item(rng, kind, churn, index, fname))
229
+
230
+ title = (commit.summary or "(no message)").strip()
231
+ if len(title) > 60:
232
+ title = title[:57] + "..."
233
+
234
+ flavor = _SIZE_FLAVOR[size]
235
+
236
+ return Room(
237
+ index=index,
238
+ sha=commit.sha,
239
+ short_sha=commit.short_sha,
240
+ title=title,
241
+ npc_name=commit.author_name,
242
+ size=size,
243
+ is_boss=is_boss,
244
+ monsters=monsters,
245
+ items=items,
246
+ languages=commit.languages,
247
+ insertions=commit.insertions,
248
+ deletions=commit.deletions,
249
+ flavor=flavor,
250
+ )
251
+
252
+
253
+ def generate_dungeon(repo: RepoData, user_seed: int | None = None) -> Dungeon:
254
+ """Build a deterministic :class:`Dungeon` from parsed repository data."""
255
+ seed = compute_seed(repo, user_seed)
256
+ rng = random.Random(seed)
257
+
258
+ depth_total = len(repo.commits)
259
+ rooms = [
260
+ _build_room(rng, commit, index, depth_total)
261
+ for index, commit in enumerate(repo.commits)
262
+ ]
263
+
264
+ return Dungeon(
265
+ repo_name=repo.name,
266
+ repo_path=repo.path,
267
+ seed=seed,
268
+ rooms=rooms,
269
+ authors=repo.authors,
270
+ language_totals=repo.language_totals,
271
+ total_insertions=repo.total_insertions,
272
+ total_deletions=repo.total_deletions,
273
+ total_commits=repo.total_commits,
274
+ sampled=repo.sampled,
275
+ merge_count=repo.merge_count,
276
+ )
277
+
278
+
279
+ if __name__ == "__main__": # pragma: no cover - manual smoke test
280
+ import sys
281
+
282
+ from .git_parser import parse_repo
283
+
284
+ repo = parse_repo(sys.argv[1] if len(sys.argv) > 1 else ".")
285
+ seed_arg = int(sys.argv[2]) if len(sys.argv) > 2 else None
286
+ dungeon = generate_dungeon(repo, seed_arg)
287
+ print(f"Dungeon for {dungeon.repo_name} seed={dungeon.seed} rooms={dungeon.size}")
288
+ for room in dungeon.rooms:
289
+ kind = "BOSS" if room.is_boss else "room"
290
+ mob = room.monsters[0]
291
+ print(
292
+ f" [{kind}] #{room.index:<3} {room.short_sha} {room.size:<9} "
293
+ f"{len(room.monsters)}m {len(room.items)}i | "
294
+ f"{mob.name} (hp{mob.hp}/atk{mob.attack}) <- {room.title[:34]}"
295
+ )