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 ADDED
@@ -0,0 +1,9 @@
1
+ """gitquest - Turn your git commit history into a playable ASCII dungeon roguelike.
2
+
3
+ Same repo + same seed = same dungeon. Explore rooms generated from real
4
+ commits, fight keyword-spawned monsters, gain language XP, and finish with a
5
+ shareable career flex card.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __all__ = ["__version__"]
gitquest/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m gitquest`` to launch the game."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
gitquest/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ """Command-line entry point and argument parsing for gitquest.
2
+
3
+ Step 1 (scaffold): wires up argument parsing and prints a placeholder banner so
4
+ ``gitquest`` and ``python -m gitquest`` both work. Later build steps replace the
5
+ placeholder with the real game loop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+
14
+ from . import __version__
15
+
16
+ BANNER = r"""
17
+ ____ _ _ ___ _
18
+ / ___(_) |_ / _ \ _ _ ___ ___| |_
19
+ | | _| | __| | | | | | |/ _ \/ __| __|
20
+ | |_| | | |_| |_| | |_| | __/\__ \ |_
21
+ \____|_|\__|\__\_\\__,_|\___||___/\__|
22
+
23
+ Your commits. Your dungeon. Your legend.
24
+ """
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ """Construct the argument parser for the ``gitquest`` command."""
29
+ parser = argparse.ArgumentParser(
30
+ prog="gitquest",
31
+ description="Turn your git commit history into a playable ASCII dungeon roguelike.",
32
+ formatter_class=argparse.RawDescriptionHelpFormatter,
33
+ epilog=(
34
+ "examples:\n"
35
+ " gitquest play on the current repo\n"
36
+ " gitquest --path ../repo play on another repo\n"
37
+ " gitquest --seed 42 force a specific dungeon seed\n"
38
+ " gitquest --stats-only skip the dungeon, just show the flex card\n"
39
+ ),
40
+ )
41
+ parser.add_argument(
42
+ "--path",
43
+ default=".",
44
+ metavar="REPO",
45
+ help="path to the git repository to play (default: current directory)",
46
+ )
47
+ parser.add_argument(
48
+ "--seed",
49
+ type=int,
50
+ default=None,
51
+ metavar="N",
52
+ help="override the procedural-generation seed (default: derived from repo)",
53
+ )
54
+ parser.add_argument(
55
+ "--fast",
56
+ action="store_true",
57
+ help="skip animations and typewriter effects",
58
+ )
59
+ parser.add_argument(
60
+ "--stats-only",
61
+ action="store_true",
62
+ help="skip gameplay and print only the final flex card",
63
+ )
64
+ parser.add_argument(
65
+ "--max-commits",
66
+ type=int,
67
+ default=300,
68
+ metavar="N",
69
+ help="cap/sample commits for very large repos (default: 300)",
70
+ )
71
+ parser.add_argument(
72
+ "--version",
73
+ action="version",
74
+ version=f"gitquest {__version__}",
75
+ )
76
+ return parser
77
+
78
+
79
+ def main(argv: list[str] | None = None) -> int:
80
+ """Entry point. Parses args and runs gitquest.
81
+
82
+ Returns a process exit code.
83
+ """
84
+ parser = build_parser()
85
+ args = parser.parse_args(argv)
86
+
87
+ # Imports are local so ``--help``/``--version`` stay fast and dependency-light.
88
+ import random
89
+
90
+ from rich.console import Console
91
+ from rich.text import Text
92
+
93
+ from .engine import GameEngine
94
+ from .entities import Player
95
+ from .flexcard import render_flexcard
96
+ from .generator import generate_dungeon
97
+ from .git_parser import (
98
+ EmptyHistoryError,
99
+ NotAGitRepoError,
100
+ parse_repo,
101
+ )
102
+ from .renderer import Renderer
103
+
104
+ console = Console()
105
+
106
+ # --- Parse the repository --------------------------------------------- #
107
+ try:
108
+ repo = parse_repo(args.path, max_commits=args.max_commits)
109
+ except NotAGitRepoError:
110
+ console.print(
111
+ f"[bold red]✗ {os.path.abspath(args.path)} is not a git repository.[/bold red]\n"
112
+ "[grey62]Run gitquest from inside a repo, or pass --path <repo>.[/grey62]"
113
+ )
114
+ return 2
115
+ except EmptyHistoryError:
116
+ console.print(
117
+ "[bold red]✗ This repository has no commits yet.[/bold red]\n"
118
+ "[grey62]Make a commit and try again — every legend starts somewhere.[/grey62]"
119
+ )
120
+ return 2
121
+ except Exception as exc: # pragma: no cover - defensive
122
+ console.print(f"[bold red]✗ Failed to read repository:[/bold red] {exc}")
123
+ return 1
124
+
125
+ # --- Generate the dungeon --------------------------------------------- #
126
+ dungeon = generate_dungeon(repo, user_seed=args.seed)
127
+ player = Player(name=repo.head.author_name or "Hero")
128
+
129
+ # --- Stats-only: silent auto run, then just the flex card ------------- #
130
+ if args.stats_only:
131
+ engine = GameEngine(dungeon, player, interactive=False)
132
+ engine.run()
133
+ render_flexcard(player, dungeon, engine.victorious, console=console)
134
+ return 0
135
+
136
+ # --- Full interactive play -------------------------------------------- #
137
+ console.print(Text(BANNER, style="bold green"))
138
+ renderer = Renderer(console=console, fast=args.fast)
139
+ rng = random.Random(dungeon.seed ^ 0x5EED)
140
+ engine = GameEngine(dungeon, player, renderer=renderer, rng=rng, interactive=True)
141
+ engine.run()
142
+
143
+ render_flexcard(player, dungeon, engine.victorious, console=console)
144
+ return 0
145
+
146
+
147
+
148
+ if __name__ == "__main__":
149
+ sys.exit(main())
gitquest/combat.py ADDED
@@ -0,0 +1,91 @@
1
+ """Lightweight, deterministic-by-rng combat math.
2
+
3
+ All functions take an explicit :class:`random.Random` so combat is fully
4
+ reproducible in tests (and during automated/demo playthroughs). The engine owns
5
+ the turn loop and player input; this module only resolves the numbers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from random import Random
12
+
13
+ from .entities import Item, Monster, Player
14
+
15
+ CRIT_CHANCE = 0.12
16
+ CRIT_MULTIPLIER = 1.8
17
+ DAMAGE_VARIANCE = 0.20
18
+ DEFEND_REDUCTION = 0.5 # fraction of incoming damage blocked while defending
19
+
20
+
21
+ @dataclass
22
+ class AttackResult:
23
+ """Outcome of a single attack."""
24
+
25
+ damage: int
26
+ crit: bool
27
+ target_defeated: bool
28
+
29
+
30
+ def compute_damage(
31
+ attack: int,
32
+ defense: int,
33
+ rng: Random,
34
+ *,
35
+ defending: bool = False,
36
+ ) -> tuple[int, bool]:
37
+ """Return ``(damage, is_crit)`` for an attack against a defense value.
38
+
39
+ Damage is ``attack`` mitigated by half the target's defense, jittered for
40
+ variety, with a chance to crit. Always at least 1 so fights end.
41
+ """
42
+ base = max(1.0, attack - defense * 0.5)
43
+ jitter = rng.uniform(1.0 - DAMAGE_VARIANCE, 1.0 + DAMAGE_VARIANCE)
44
+ crit = rng.random() < CRIT_CHANCE
45
+ dmg = base * jitter
46
+ if crit:
47
+ dmg *= CRIT_MULTIPLIER
48
+ if defending:
49
+ dmg *= 1.0 - DEFEND_REDUCTION
50
+ return max(1, int(round(dmg))), crit
51
+
52
+
53
+ def player_attacks(player: Player, monster: Monster, rng: Random) -> AttackResult:
54
+ """Resolve the player's attack on a monster, mutating monster HP."""
55
+ dmg, crit = compute_damage(player.attack, monster.defense, rng)
56
+ monster.hp = max(0, monster.hp - dmg)
57
+ player.damage_dealt += dmg
58
+ return AttackResult(damage=dmg, crit=crit, target_defeated=not monster.is_alive)
59
+
60
+
61
+ def monster_attacks(
62
+ monster: Monster,
63
+ player: Player,
64
+ rng: Random,
65
+ *,
66
+ player_defending: bool = False,
67
+ ) -> AttackResult:
68
+ """Resolve a monster's attack on the player, mutating player HP."""
69
+ dmg, crit = compute_damage(
70
+ monster.attack, player.defense, rng, defending=player_defending
71
+ )
72
+ player.hp = max(0, player.hp - dmg)
73
+ player.damage_taken += dmg
74
+ return AttackResult(damage=dmg, crit=crit, target_defeated=not player.is_alive)
75
+
76
+
77
+ def use_potion(player: Player, potion: Item) -> int:
78
+ """Consume a potion, healing the player. Returns HP actually restored."""
79
+ if potion not in player.inventory:
80
+ return 0
81
+ before = player.hp
82
+ player.hp = min(player.max_hp, player.hp + potion.value)
83
+ player.inventory.remove(potion)
84
+ player.potions_used += 1
85
+ return player.hp - before
86
+
87
+
88
+ def choose_monster_target(monster: Monster, rng: Random) -> str:
89
+ """Flavor helper: pick how a monster telegraphs its attack."""
90
+ verbs = ["lunges", "swipes", "hurls a stack trace", "casts a regex", "bites"]
91
+ return rng.choice(verbs)
@@ -0,0 +1,38 @@
1
+ {
2
+ "potion": [
3
+ "Vial of Hotfix",
4
+ "Caffeine Elixir",
5
+ "Rubber Duck Tonic",
6
+ "Stack Trace Salve",
7
+ "Green Build Brew"
8
+ ],
9
+ "weapon": [
10
+ "Refactoring Blade",
11
+ "Debugger Dagger",
12
+ "Linter Lance",
13
+ "Git Blame Bow",
14
+ "Commandline Cutlass",
15
+ "Regex Rapier"
16
+ ],
17
+ "armor": [
18
+ "Test Coverage Cloak",
19
+ "Type-Hint Hauberk",
20
+ "CI Shield",
21
+ "Mutex Mail",
22
+ "Exception Handler Helm"
23
+ ],
24
+ "relic": [
25
+ "Idol of Clean Code",
26
+ "Ancient Changelog",
27
+ "Tome of Documentation",
28
+ "Sacred .gitignore",
29
+ "Charm of Code Review"
30
+ ],
31
+ "treasure": [
32
+ "Pouch of Story Points",
33
+ "Bag of Bounty Bug Rewards",
34
+ "Cache of Merge Credits",
35
+ "Sprint Velocity Coins",
36
+ "Open Source Stars"
37
+ ]
38
+ }
@@ -0,0 +1,106 @@
1
+ {
2
+ "Bug": {
3
+ "keywords": ["fix", "bug", "hotfix", "patch", "issue", "error", "crash", "broke", "broken"],
4
+ "art": "(>_<)",
5
+ "names": [
6
+ "Null Pointer",
7
+ "Off-by-One",
8
+ "Race Condition",
9
+ "Segfault Spawn",
10
+ "Heisenbug",
11
+ "Memory Leak",
12
+ "Stack Overflow",
13
+ "Infinite Loop",
14
+ "Dangling Reference",
15
+ "Deadlock Drake"
16
+ ]
17
+ },
18
+ "Feature": {
19
+ "keywords": ["feat", "feature", "add", "implement", "introduce", "new", "create", "support"],
20
+ "art": "(o_O)",
21
+ "names": [
22
+ "Scope Creep",
23
+ "Spec Wraith",
24
+ "Requirement Hydra",
25
+ "Edge Case Imp",
26
+ "Backlog Beast",
27
+ "Feature Flag Fiend",
28
+ "MVP Marauder",
29
+ "Roadmap Reaver"
30
+ ]
31
+ },
32
+ "Refactor": {
33
+ "keywords": ["refactor", "cleanup", "clean", "rename", "restructure", "simplify", "tidy", "reorganize"],
34
+ "art": "(~_~)",
35
+ "names": [
36
+ "Refactor Wraith",
37
+ "Tech Debt Golem",
38
+ "Spaghetti Serpent",
39
+ "Legacy Lurker",
40
+ "Coupling Creeper",
41
+ "God Object",
42
+ "Circular Dependency"
43
+ ]
44
+ },
45
+ "Test": {
46
+ "keywords": ["test", "tests", "spec", "coverage", "ci", "pytest", "unit"],
47
+ "art": "(x_x)",
48
+ "names": [
49
+ "Flaky Test",
50
+ "Red Build",
51
+ "Mock Mimic",
52
+ "Assertion Apparition",
53
+ "Coverage Gap",
54
+ "Timeout Terror"
55
+ ]
56
+ },
57
+ "Docs": {
58
+ "keywords": ["docs", "doc", "documentation", "readme", "comment", "typo", "spelling"],
59
+ "art": "(._.)",
60
+ "names": [
61
+ "Doc Golem",
62
+ "Typo Sprite",
63
+ "Lorem Ipsum Lich",
64
+ "Stale Comment",
65
+ "Markdown Mummy",
66
+ "TODO Phantom"
67
+ ]
68
+ },
69
+ "Build": {
70
+ "keywords": ["build", "deps", "dependency", "bump", "release", "version", "config", "chore", "deploy", "docker"],
71
+ "art": "(O_O)",
72
+ "names": [
73
+ "Dependency Demon",
74
+ "Version Conflict",
75
+ "Broken Pipeline",
76
+ "Config Cultist",
77
+ "Yaml Yeti",
78
+ "Docker Daemon"
79
+ ]
80
+ },
81
+ "Imp": {
82
+ "keywords": [],
83
+ "art": "(o_o)",
84
+ "names": [
85
+ "Commit Imp",
86
+ "Diff Goblin",
87
+ "Whitespace Wisp",
88
+ "Merge Gremlin",
89
+ "Branch Bandit",
90
+ "Stash Sprite",
91
+ "Rebase Wraith"
92
+ ]
93
+ },
94
+ "Boss": {
95
+ "keywords": ["merge"],
96
+ "art": "(\u00ac_\u00ac)",
97
+ "names": [
98
+ "The Merge Conflict",
99
+ "Octopus Merge Overlord",
100
+ "Diverged HEAD",
101
+ "The Rebase Tyrant",
102
+ "Force-Push Phantom",
103
+ "Detached HEAD Hydra"
104
+ ]
105
+ }
106
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "ranks": [
3
+ { "title": "Script Kiddie", "min_score": 0 },
4
+ { "title": "Code Squire", "min_score": 150 },
5
+ { "title": "Commit Knight", "min_score": 400 },
6
+ { "title": "Merge Marauder", "min_score": 800 },
7
+ { "title": "Refactor Ranger", "min_score": 1400 },
8
+ { "title": "Senior Crusader", "min_score": 2200 },
9
+ { "title": "Staff Sorcerer", "min_score": 3200 },
10
+ { "title": "Principal Paladin", "min_score": 4500 },
11
+ { "title": "Architect Archmage", "min_score": 6500 },
12
+ { "title": "10x Demigod", "min_score": 9000 }
13
+ ]
14
+ }
gitquest/engine.py ADDED
@@ -0,0 +1,217 @@
1
+ """Game loop and state orchestration.
2
+
3
+ The engine walks the player through the dungeon room by room (oldest commit ->
4
+ HEAD), running combat, distributing loot, and handling progression. It supports
5
+ two modes:
6
+
7
+ * **interactive** – renders via :class:`~gitquest.renderer.Renderer` and prompts
8
+ the player for combat actions.
9
+ * **auto** – resolves everything silently and deterministically (driven by the
10
+ seeded RNG). Used by ``--stats-only`` and by tests to simulate a full run.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import random
16
+
17
+ from . import combat
18
+ from .entities import Dungeon, Item, ItemKind, Player, Room
19
+ from .renderer import Renderer
20
+
21
+ # Player retreats from a fight only this often (keeps runs honest).
22
+ RUN_SUCCESS_CHANCE = 0.5
23
+ # Auto-play drinks a potion when HP drops below this fraction of max.
24
+ AUTO_POTION_THRESHOLD = 0.4
25
+
26
+
27
+ class GameEngine:
28
+ """Drives a single playthrough of a generated dungeon."""
29
+
30
+ def __init__(
31
+ self,
32
+ dungeon: Dungeon,
33
+ player: Player,
34
+ renderer: Renderer | None = None,
35
+ rng: random.Random | None = None,
36
+ *,
37
+ interactive: bool = True,
38
+ ) -> None:
39
+ self.dungeon = dungeon
40
+ self.player = player
41
+ self.renderer = renderer
42
+ self.interactive = interactive
43
+ # Combat RNG is seeded from the dungeon so auto runs are reproducible.
44
+ self.rng = rng or random.Random(dungeon.seed ^ 0x5EED)
45
+
46
+ # -- public API -------------------------------------------------------- #
47
+
48
+ def run(self) -> Player:
49
+ """Play through every room. Returns the (mutated) player."""
50
+ if self.interactive and self.renderer:
51
+ self.renderer.intro(self.dungeon, self.player)
52
+ self.renderer.pause("Press [bold]Enter[/bold] to enter the dungeon")
53
+
54
+ for room in self.dungeon.rooms:
55
+ self._enter_room(room)
56
+ if not self.player.is_alive:
57
+ if self.interactive and self.renderer:
58
+ self.renderer.player_died(self.player, room)
59
+ break
60
+
61
+ return self.player
62
+
63
+ @property
64
+ def victorious(self) -> bool:
65
+ return self.player.is_alive
66
+
67
+ # -- room flow --------------------------------------------------------- #
68
+
69
+ def _enter_room(self, room: Room) -> None:
70
+ if self.interactive and self.renderer:
71
+ self.renderer.room_header(room, room.index, self.dungeon.size)
72
+
73
+ # Languages coded in this commit fill the skill tree.
74
+ self.player.grant_language_xp(room.languages)
75
+ if self.interactive and self.renderer:
76
+ self.renderer.language_gain(room.languages)
77
+
78
+ # Fight everything that lives here.
79
+ for monster in room.monsters:
80
+ if not monster.is_alive:
81
+ continue
82
+ if self.interactive and self.renderer:
83
+ self.renderer.encounter(monster)
84
+ self._fight(monster)
85
+ if not self.player.is_alive:
86
+ return
87
+
88
+ # Loot + room-clear reward.
89
+ self._collect_loot(room)
90
+ clear_bonus = 5 + room.index
91
+ levels = self.player.add_xp(clear_bonus)
92
+ self.player.rooms_cleared += 1
93
+ if self.interactive and self.renderer:
94
+ if levels:
95
+ self.renderer.level_up(self.player, levels)
96
+ self.renderer.room_cleared(room)
97
+ self.renderer.pause()
98
+
99
+ # -- combat ------------------------------------------------------------ #
100
+
101
+ def _fight(self, monster) -> None:
102
+ """Run a turn-based duel until the monster or player falls (or flees)."""
103
+ while monster.is_alive and self.player.is_alive:
104
+ action = self._choose_action(monster)
105
+
106
+ if action == "run":
107
+ if self.rng.random() < RUN_SUCCESS_CHANCE:
108
+ if self.interactive and self.renderer:
109
+ self.renderer.run_line(True)
110
+ return
111
+ if self.interactive and self.renderer:
112
+ self.renderer.run_line(False)
113
+ self._monster_turn(monster, player_defending=False)
114
+ continue
115
+
116
+ defending = action == "defend"
117
+
118
+ if action == "item":
119
+ used = self._use_potion()
120
+ if not used:
121
+ # Cancelled / no potion -> turn not spent; re-prompt.
122
+ if not self.interactive:
123
+ defending = False # fall through to attack in auto mode
124
+ else:
125
+ continue
126
+
127
+ if action == "attack":
128
+ result = combat.player_attacks(self.player, monster, self.rng)
129
+ if self.interactive and self.renderer:
130
+ self.renderer.attack_line(
131
+ self.player.name, monster.name, result.damage, result.crit, "green"
132
+ )
133
+ if result.target_defeated:
134
+ self._reward_kill(monster)
135
+ return
136
+ elif action == "defend" and self.interactive and self.renderer:
137
+ self.renderer.defend_line(self.player)
138
+
139
+ # Monster retaliates.
140
+ self._monster_turn(monster, player_defending=defending)
141
+
142
+ if self.interactive and self.renderer:
143
+ self.renderer.combat_status(self.player, monster)
144
+
145
+ def _monster_turn(self, monster, *, player_defending: bool) -> None:
146
+ if not monster.is_alive:
147
+ return
148
+ result = combat.monster_attacks(
149
+ monster, self.player, self.rng, player_defending=player_defending
150
+ )
151
+ if self.interactive and self.renderer:
152
+ color = "bright_red" if monster.is_boss else "red"
153
+ self.renderer.attack_line(
154
+ monster.name, self.player.name, result.damage, result.crit, color
155
+ )
156
+
157
+ def _choose_action(self, monster) -> str:
158
+ if self.interactive and self.renderer:
159
+ return self.renderer.prompt_action(self.player)
160
+ # Auto strategy: heal when low and potions exist, else attack.
161
+ if (
162
+ self.player.potions
163
+ and self.player.hp < self.player.max_hp * AUTO_POTION_THRESHOLD
164
+ ):
165
+ return "item"
166
+ return "attack"
167
+
168
+ def _use_potion(self) -> bool:
169
+ if self.interactive and self.renderer:
170
+ potion = self.renderer.choose_potion(self.player)
171
+ else:
172
+ potions = self.player.potions
173
+ potion = potions[0] if potions else None
174
+ if potion is None:
175
+ return False
176
+ healed = combat.use_potion(self.player, potion)
177
+ if self.interactive and self.renderer:
178
+ self.renderer.heal_line(self.player, potion, healed)
179
+ return True
180
+
181
+ def _reward_kill(self, monster) -> None:
182
+ self.player.gold += monster.gold_reward
183
+ levels = self.player.add_xp(monster.xp_reward)
184
+ self.player.kills += 1
185
+ if monster.is_boss:
186
+ self.player.bosses_slain += 1
187
+ if self.interactive and self.renderer:
188
+ self.renderer.monster_defeated(monster, monster.xp_reward, monster.gold_reward)
189
+ if levels:
190
+ self.renderer.level_up(self.player, levels)
191
+
192
+ # -- loot -------------------------------------------------------------- #
193
+
194
+ def _collect_loot(self, room: Room) -> None:
195
+ if not room.items:
196
+ if self.interactive and self.renderer:
197
+ self.renderer.no_loot()
198
+ return
199
+ for item in room.items:
200
+ self._apply_item(item)
201
+ self.player.items_collected += 1
202
+ if self.interactive and self.renderer:
203
+ self.renderer.loot(item)
204
+
205
+ def _apply_item(self, item: Item) -> None:
206
+ """Permanently apply non-potion loot; stash potions for combat use."""
207
+ if item.kind == ItemKind.POTION:
208
+ self.player.inventory.append(item)
209
+ elif item.kind == ItemKind.WEAPON:
210
+ self.player.attack += item.value
211
+ elif item.kind == ItemKind.ARMOR:
212
+ self.player.defense += item.value
213
+ elif item.kind == ItemKind.RELIC:
214
+ self.player.max_hp += item.value
215
+ self.player.hp += item.value
216
+ elif item.kind == ItemKind.TREASURE:
217
+ self.player.gold += item.value