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/__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
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)
|
gitquest/data/items.json
ADDED
|
@@ -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
|
+
}
|
gitquest/data/ranks.json
ADDED
|
@@ -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
|