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/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
|
+
)
|