Fortschritts-Quest v1.0 — Automatisches RPG mit lokalem LLM

This commit is contained in:
arch_agent
2026-07-25 18:39:29 +02:00
commit f3f165bf78
3270 changed files with 542895 additions and 0 deletions
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+162
View File
@@ -0,0 +1,162 @@
"""Character-System: Rassen, Klassen, Stats, Leveling."""
import random
import math
RACES = [
"Kleinwüchsiger Goblin", "Waldelf", "Höhlentroll", "Stadtmensch",
"Wüstenork", "Sumpfzwerg", "Eisbarbar", "Schattenkatze",
"Steingolem", "Feuerechse", "Nachtalb", "Wolfsjäger",
]
CLASSES = [
"Schlachtenbraten", "Kartoffelmagier", "Panzerschmied", "Bratpfannenpaladin",
"Würstchenkrieger", "Kaffeepriester", "Bierbrauer-Mönch", "Käsekönigin",
"Dosenöffner-Assassin", "Fahrradschurke", "Schneeballhexer", "Teppichkampfkünstler",
]
# Class → stat weights (STR, DEX, INT, VIT, LCK)
CLASS_STATS = {
"Schlachtenbraten": (8, 3, 1, 7, 2),
"Kartoffelmagier": (2, 3, 8, 3, 5),
"Panzerschmied": (7, 2, 3, 8, 1),
"Bratpfannenpaladin": (6, 4, 4, 6, 3),
"Würstchenkrieger": (7, 5, 2, 5, 4),
"Kaffeepriester": (3, 4, 7, 3, 6),
"Bierbrauer-Mönch": (5, 3, 5, 7, 3),
"Käsekönigin": (4, 5, 6, 4, 5),
"Dosenöffner-Assassin": (4, 8, 3, 3, 7),
"Fahrradschurke": (3, 7, 4, 4, 8),
"Schneeballhexer": (2, 4, 8, 3, 5),
"Teppichkampfkünstler": (5, 7, 3, 5, 4),
}
# XP curve: xp needed for next level = base * level^1.5
def xp_for_level(level: int) -> int:
return int(50 * (level ** 1.5))
class Character:
def __init__(self, name: str, race: str, char_class: str):
self.name = name
self.race = race
self.char_class = char_class
self.level = 1
self.xp = 0
self.gold = 10
stats = CLASS_STATS.get(char_class, (5, 5, 5, 5, 5))
self.str = stats[0] + random.randint(0, 2)
self.dex = stats[1] + random.randint(0, 2)
self.int = stats[2] + random.randint(0, 2)
self.vit = stats[3] + random.randint(0, 2)
self.lck = stats[4] + random.randint(0, 2)
self.hp = self.max_hp
self.mp = self.max_mp
self.inventory = []
self.equipped = {"weapon": None, "armor": None, "helmet": None, "boots": None, "accessory": None}
self.kills = 0
self.deaths = 0
self.quests_done = 0
self.steps = 0
@property
def max_hp(self) -> int:
return 20 + self.vit * 5 + self.level * 3
@property
def max_mp(self) -> int:
return 10 + self.int * 3 + self.level * 2
@property
def attack(self) -> int:
weapon_bonus = self.equipped["weapon"].power if self.equipped["weapon"] else 0
return self.str * 2 + self.level + weapon_bonus
@property
def defense(self) -> int:
armor_bonus = sum(e.power for e in self.equipped.values() if e)
return self.vit + armor_bonus
@property
def xp_to_next(self) -> int:
return xp_for_level(self.level)
def gain_xp(self, amount: int) -> bool:
"""Gibt XP. Return True bei Level-Up."""
self.xp += amount
if self.xp >= self.xp_to_next:
self.xp -= self.xp_to_next
self.level += 1
self.hp = self.max_hp
self.mp = self.max_mp
# Stat growth
self.str += random.randint(0, 1)
self.dex += random.randint(0, 1)
self.int += random.randint(0, 1)
self.vit += random.randint(0, 1)
self.lck += random.randint(0, 1)
return True
return False
def take_damage(self, dmg: int):
actual = max(1, dmg - self.defense)
self.hp -= actual
return actual
def heal(self):
self.hp = self.max_hp
self.mp = self.max_mp
def is_alive(self) -> bool:
return self.hp > 0
def to_dict(self) -> dict:
return {
"name": self.name, "race": self.race, "char_class": self.char_class,
"level": self.level, "xp": self.xp, "gold": self.gold,
"str": self.str, "dex": self.dex, "int": self.int, "vit": self.vit, "lck": self.lck,
"hp": self.hp, "mp": self.mp,
"kills": self.kills, "deaths": self.deaths, "quests_done": self.quests_done, "steps": self.steps,
"inventory": [i.to_dict() for i in self.inventory],
"equipped": {k: v.to_dict() if v else None for k, v in self.equipped.items()},
}
@classmethod
def from_dict(cls, d: dict) -> "Character":
c = cls(d["name"], d["race"], d["char_class"])
c.level = d["level"]; c.xp = d["xp"]; c.gold = d["gold"]
c.str = d["str"]; c.dex = d["dex"]; c.int = d["int"]; c.vit = d["vit"]; c.lck = d["lck"]
c.hp = d["hp"]; c.mp = d["mp"]
c.kills = d["kills"]; c.deaths = d["deaths"]; c.quests_done = d["quests_done"]; c.steps = d["steps"]
c.inventory = [Item.from_dict(i) for i in d.get("inventory", [])]
c.equipped = {
k: Item.from_dict(v) if v else None
for k, v in d.get("equipped", {}).items()
}
return c
class Item:
def __init__(self, name: str, item_type: str, power: int, value: int, rarity: str = "gewöhnlich"):
self.name = name
self.item_type = item_type # weapon, armor, helmet, boots, accessory, junk, potion
self.power = power
self.value = value
self.rarity = rarity
def to_dict(self) -> dict:
return {"name": self.name, "item_type": self.item_type, "power": self.power, "value": self.value, "rarity": self.rarity}
@classmethod
def from_dict(cls, d: dict) -> "Item":
return cls(d["name"], d["item_type"], d["power"], d["value"], d.get("rarity", "gewöhnlich"))
def __str__(self):
r = {"gewöhnlich": "", "selten": "[cyan]", "episch": "[magenta]", "legendär": "[bold yellow]"}
color = r.get(self.rarity, "")
prefix = f"{color}" if color else ""
suffix = "[/]" if color else ""
return f"{prefix}{self.name} (Stärke {self.power}, {self.value} Gold){suffix}"
+241
View File
@@ -0,0 +1,241 @@
"""Game-Engine: Auto-Progress, Kämpfe, Loot, Quests, Shop."""
import random
import time
from character import Character, Item, RACES, CLASSES
from llm import NameCache
class Monster:
def __init__(self, name: str, level: int, hp: int, attack: int, defense: int, xp: int, gold: int):
self.name = name
self.level = level
self.hp = hp
self.attack = attack
self.defense = defense
self.xp = xp
self.gold = gold
def is_alive(self):
return self.hp > 0
class GameEngine:
def __init__(self, character: Character):
self.character = character
self.cache = NameCache()
self.cache.refill_async()
self.current_location = self.cache.get_location()
self.current_quest = self.cache.get_quest(character.level)
self.quest_progress = 0
self.quest_target = random.randint(3, 8)
self.log = []
self.max_log = 15
def _add_log(self, msg: str):
self.log.insert(0, msg)
if len(self.log) > self.max_log:
self.log = self.log[:self.max_log]
def _spawn_monster(self) -> Monster:
name = self.cache.get_monster(self.character.level, self.current_location)
level = max(1, self.character.level + random.randint(-1, 1))
hp = 10 + level * 5 + random.randint(0, 10)
attack = level * 2 + random.randint(0, 3)
defense = level + random.randint(0, 2)
xp = 5 + level * 3 + random.randint(0, 5)
gold = 1 + level + random.randint(0, 3)
return Monster(name, level, hp, attack, defense, xp, gold)
def _generate_loot(self, monster: Monster) -> Item:
item_types = ["weapon", "armor", "helmet", "boots", "accessory", "junk"]
item_type = random.choices(item_types, weights=[15, 15, 10, 10, 8, 42])[0]
# Rarity based on luck and level
roll = random.randint(1, 100) + self.character.lck
if roll > 95:
rarity = "legendär"
elif roll > 80:
rarity = "episch"
elif roll > 60:
rarity = "selten"
else:
rarity = "gewöhnlich"
name = self.cache.get_item(item_type, self.character.level, rarity)
rarity_mult = {"gewöhnlich": 1, "selten": 1.5, "episch": 2.5, "legendär": 4}
mult = rarity_mult.get(rarity, 1)
base_power = self.character.level + random.randint(0, 3)
power = max(1, int(base_power * mult))
value = max(1, int((monster.gold + 2) * mult * random.uniform(0.8, 1.2)))
return Item(name, item_type, power, value, rarity)
def _combat(self, monster: Monster) -> dict:
"""Führt einen automatischen Kampf durch. Gibt Ergebnis-Dict zurück."""
rounds = 0
while monster.is_alive() and self.character.is_alive():
rounds += 1
# Character attacks
char_dmg = max(1, self.character.attack - monster.defense + random.randint(-2, 2))
char_dmg = min(char_dmg, monster.hp)
monster.hp -= char_dmg
if not monster.is_alive():
break
# Monster attacks
mon_dmg = max(1, monster.attack - self.character.defense + random.randint(-2, 2))
actual = self.character.take_damage(mon_dmg)
if not self.character.is_alive():
return {"result": "death", "rounds": rounds, "monster": monster.name}
return {"result": "victory", "rounds": rounds, "monster": monster.name}
def _complete_quest(self):
self.character.quests_done += 1
quest_gold = 5 + self.character.level * 3 + random.randint(0, 10)
quest_xp = 10 + self.character.level * 5 + random.randint(0, 10)
self.character.gold += quest_gold
leveled = self.character.gain_xp(quest_xp)
self._add_log(f"[bold green]✅ Quest abgeschlossen: {self.current_quest} (+{quest_gold} Gold, +{quest_xp} XP)[/]")
if leveled:
self._add_log(f"[bold yellow]⬆️ Stufe {self.character.level} erreicht![/]")
self.character.heal()
# New quest
self.current_quest = self.cache.get_quest(self.character.level)
self.quest_progress = 0
self.quest_target = random.randint(3, 8)
self._add_log(f"[cyan]📜 Neue Quest: {self.current_quest} ({self.quest_target} Gegner)[/]")
def step(self) -> dict:
"""Ein Schritt des Auto-Progress. Gibt Event-Info zurück."""
self.character.steps += 1
events = []
# Random: sometimes travel
if random.randint(1, 50) == 1:
self.current_location = self.cache.get_location()
events.append(("travel", f"[blue]🌍 Du reist nach: {self.current_location}[/]"))
self._add_log(events[-1][1])
# Spawn monster
monster = self._spawn_monster()
events.append(("encounter", f"[red]⚔️ {monster.name} (Stufe {monster.level}) erscheint![/]"))
self._add_log(events[-1][1])
# Fight
result = self._combat(monster)
if result["result"] == "victory":
self.character.kills += 1
events.append(("victory", f"[green]⚔️ Besiegst {monster.name} ({result['rounds']} Runden)[/]"))
self._add_log(events[-1][1])
# XP + Gold
self.character.gold += monster.gold
leveled = self.character.gain_xp(monster.xp)
events.append(("loot_xp", f"[dim]+{monster.xp} XP, +{monster.gold} Gold[/]"))
self._add_log(events[-1][1])
if leveled:
self._add_log(f"[bold yellow]⬆️ Stufe {self.character.level} erreicht![/]")
self.character.heal()
events.append(("levelup", f"[bold yellow]⬆️ STUFE {self.character.level}![/]"))
# Loot drop
if random.randint(1, 100) <= 70: # 70% drop chance
item = self._generate_loot(monster)
self._add_log(f"[magenta]📦 Loot: {item}[/]")
events.append(("loot", f"[magenta]📦 {item}[/]", item))
self._auto_equip(item)
else:
# Always some gold
extra_gold = random.randint(1, 3)
self.character.gold += extra_gold
# Quest progress
self.quest_progress += 1
if self.quest_progress >= self.quest_target:
self._complete_quest()
events.append(("quest_done", f"[green]✅ Quest erfüllt![/]"))
elif result["result"] == "death":
self.character.deaths += 1
events.append(("death", f"[bold red]💀 Von {monster.name} besiegt! Respawn...[/]"))
self._add_log(events[-1][1])
# Respawn: lose 10% gold, heal
lost_gold = max(1, self.character.gold // 10)
self.character.gold -= lost_gold
self.character.heal()
self._add_log(f"[red]Verlierst {lost_gold} Gold beim Sterben.[/]")
return {"events": events}
def _auto_equip(self, item: Item):
"""Prüft ob Item besser ist und rüstet es automatisch aus."""
slot = None
if item.item_type in ("weapon", "armor", "helmet", "boots", "accessory"):
slot = item.item_type
if slot:
current = self.character.equipped[slot]
if current is None or item.power > current.power:
if current:
self.character.inventory.append(current)
self.character.equipped[slot] = item
self._add_log(f"[cyan]🎽 Ausgerüstet: {item}[/]")
else:
self.character.inventory.append(item)
else:
self.character.inventory.append(item)
# Auto-sell junk to keep inventory clean
if item.item_type == "junk":
self.character.gold += item.value
if item in self.character.inventory:
self.character.inventory.remove(item)
self._add_log(f"[dim]💰 Verkauft: {item.name} (+{item.value} Gold)[/]")
def _sell_junk(self):
"""Verkauft alle Schrott-Items."""
sold = 0
total_gold = 0
for item in self.character.inventory[:]:
if item.item_type == "junk":
self.character.inventory.remove(item)
self.character.gold += item.value
total_gold += item.value
sold += 1
if sold > 0:
self._add_log(f"[dim]💰 {sold} Schrott verkauft (+{total_gold} Gold)[/]")
def get_state(self) -> dict:
return {
"character": self.character.to_dict(),
"current_location": self.current_location,
"current_quest": self.current_quest,
"quest_progress": self.quest_progress,
"quest_target": self.quest_target,
"log": self.log,
}
def save(self, path: str):
import json
with open(path, "w") as f:
json.dump(self.get_state(), f, ensure_ascii=False, indent=2)
@classmethod
def load(cls, path: str) -> "GameEngine":
import json
with open(path) as f:
d = json.load(f)
char = Character.from_dict(d["character"])
engine = cls(char)
engine.current_location = d.get("current_location", engine.current_location)
engine.current_quest = d.get("current_quest", engine.current_quest)
engine.quest_progress = d.get("quest_progress", 0)
engine.quest_target = d.get("quest_target", 5)
engine.log = d.get("log", [])
return engine
+216
View File
@@ -0,0 +1,216 @@
"""Ollama LLM-Integration für dynamische Namen und Ereignisse."""
import httpx
import json
import random
import os
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "gemma4:12b")
# Fallback-Listen falls Ollama nicht erreichbar
FALLBACK_MONSTERS = [
"Wütender Bratwurst-Golem", "Verirrter Bürokrat", "Sabbernder Höhlentroll",
"Betrunkener Kartoffelgeist", "Kaffeeriese", "Einsamer Würstchenkobold",
"Müder Bäcker-Ghoul", "Brüllender Dosenöffner", "Schlafender Schneeball",
"Verzweifelter Parkplatzwächter", "Tanzen Milchgeist", "Rostiger Fahrraddämon",
"Papiertiger aus Pankow", "Neuköllner Einkaufswagen", "Flüchtiger Socken-Golem",
"Grammatik-Nazi aus Kreuzberg", "Mitternachts-Brötchen", "Heimtückischer Toaster",
]
FALLBACK_ITEMS = {
"weapon": ["Rostige Bratpfanne", "Verbeulter Dosenöffner", "Alte Wurstzange", "Verbogenes Fahrrad-Pedal"],
"armor": ["Kaffeefleck-Kettenhemd", "Pappkarton-Rüstung", "Mülltonnenschild", "Regenmantel des Grauens"],
"helmet": ["Kochtopf mit Dellen", "Eimer mit Loch", "Tasse als Helm", "Plastik-Eimer"],
"boots": ["Badeschlappen des Schreckens", "Holzschuhe mit Nagel", "Gummistiefel ohne Sohle"],
"accessory": ["Rubberband der Macht", "Kaugummi-Kristall", "Wollmütze +2", "Fahrradklingel"],
"junk": ["Alte Zeitung", "Leerer Kaffeebecher", "Heruntergefallenes Brötchen", "Verbogener Löffel"],
}
FALLBACK_QUESTS = [
"Bringe 3 Würstchen zum Bürgermeister", "Finde den verlorenen Kaffeelöffel",
"Befreie das Dorf von der Bratwurst-Plage", "Begleite die Schneeballhexe zum Markt",
"Sammle 10 leere Pfandflaschen", "Besiege den Riesen-Gartenzwerg",
"Befreie die gefangene Wurstware", "Finde den verschwundenen Dosenöffner",
"Reinige die Küche der ewigen Fettflecken", "Begleite den alten Bierbrauer nach Hause",
]
FALLBACK_LOCATIONS = [
"Neukölln bei Nacht", "Dem Schornstein hinterher", "Das ewige Baustellen-Nirwana",
"Der Tresen der Verzweiflung", "Die große Kartoffel-Höhle", "Berlin Mitte im Stau",
"Die Döner-Insel", "Die verlassene Wäscherei", "Der S-Bahn-Ring des Grauens",
]
def _clean_name(result: str) -> str:
"""Säubert LLM-Output: entfernt Metadaten, Anführungszeichen, überflüssige Wörter."""
if not result:
return ""
# Remove metadata-like suffixes: _Wütend_Stufe5, _betrunken, etc.
result = result.split("_")[0] if "_" in result and not result.startswith("_") else result
# Remove common LLM preambles
for prefix in ["Der Name lautet: ", "Name: ", "Monster: ", "Quest: ", "Ort: ", "Hier ist", "Das ist"]:
if result.lower().startswith(prefix.lower()):
result = result[len(prefix):]
# Strip quotes, punctuation, whitespace
result = result.strip('"\'.!?*#\n\r ')
# Capitalize first letter
if result:
result = result[0].upper() + result[1:]
return result
def _call_ollama(prompt: str, model: str = None, temperature: float = 0.9) -> str:
"""Ruft Ollama auf und gibt die Antwort als String zurück."""
model = model or DEFAULT_MODEL
try:
with httpx.Client(timeout=30) as client:
resp = client.post(f"{OLLAMA_URL}/api/generate", json={
"model": model,
"prompt": prompt,
"stream": False,
"think": False,
"options": {"temperature": temperature, "num_predict": 60},
})
data = resp.json()
return data.get("response", "").strip()
except Exception as e:
return ""
def generate_monster_name(player_level: int, location: str = "") -> str:
"""Generiert einen absurden Monsternamen auf Deutsch."""
threat = random.choice(["lächerlich", "gefährlich", "müde", "wütend", "verwirrt", "betrunken", "verliebt", "niedergeschlagen"])
prompt = f"""Du bist ein Generator für absurde deutsche RPG-Monster. Generiere EINEN kurzen, lustigen Monsternamen auf Deutsch.
Der Name soll absurd und komisch sein, wie ein Satire-RPG (Progress Quest).
Kombinationen wie "Bratwurst-Golem", "Bürokraten-Spinne", "Kartoffel-Drache" etc.
Das Monster ist {threat} und auf Stufe {player_level}.
Antworte NUR mit dem Namen, kein Satz, keine Erklärung, keine Anführungszeichen."""
result = _call_ollama(prompt, temperature=1.0)
result = _clean_name(result)
if result and len(result) < 50:
return result
return random.choice(FALLBACK_MONSTERS)
def generate_item_name(item_type: str, player_level: int, rarity: str = "gewöhnlich") -> str:
"""Generiert einen absurden Item-Namen auf Deutsch."""
prompt = f"""Du bist ein Generator für absurde deutsche RPG-Gegenstände. Generiere EINEN kurzen, lustigen Gegenstandsnamen auf Deutsch.
Typ: {item_type} (Waffe, Rüstung, Helm, Stiefel, Accessoire, Schrott)
Seltenheit: {rarity}
Stufe: {player_level}
Beispiele: "Rostige Bratpfanne", "Verbeulter Dosenöffner", "Kaffeefleck-Kettenhemd", "Badeschlappen des Schreckens".
Antworte NUR mit dem Namen, kein Satz, keine Erklärung, keine Anführungszeichen."""
result = _call_ollama(prompt, temperature=1.0)
result = _clean_name(result)
if result and len(result) < 50:
return result
return random.choice(FALLBACK_ITEMS.get(item_type, FALLBACK_ITEMS["junk"]))
def generate_quest_name(player_level: int) -> str:
"""Generiert eine absurde Quest-Beschreibung auf Deutsch."""
prompt = f"""Du bist ein Generator für absurde deutsche RPG-Quests (Progress Quest Stil).
Generiere EINE kurze, lustige Quest-Beschreibung auf Deutsch.
Stufe: {player_level}
Beispiele: "Bringe 3 Würstchen zum Bürgermeister", "Finde den verlorenen Kaffee", "Sammle 10 Pfandflaschen", "Besiege den Riesen-Gartenzwerg".
Antworte NUR mit der Quest, kein Satz, keine Erklärung, keine Anführungszeichen."""
result = _call_ollama(prompt, temperature=1.0)
result = _clean_name(result)
if result and len(result) < 80:
return result
return random.choice(FALLBACK_QUESTS)
def generate_location_name() -> str:
"""Generiert einen absurden Ortsnamen auf Deutsch."""
prompt = f"""Du bist ein Generator für absurde deutsche RPG-Orte (Progress Quest Satire).
Generiere EINEN kurzen, lustigen Ortsnamen auf Deutsch.
Beispiele: "Neukölln bei Nacht", "Die große Kartoffel-Höhle", "Berlin Mitte im Stau", "Die Döner-Insel".
Antworte NUR mit dem Ortsnamen, kein Satz, keine Erklärung, keine Anführungszeichen."""
result = _call_ollama(prompt, temperature=1.0)
result = _clean_name(result)
if result and len(result) < 50:
return result
return random.choice(FALLBACK_LOCATIONS)
def generate_combat_text(monster: str, action: str = "attack") -> str:
"""Generiert eine kurze Kampfbeschreibung."""
if action == "kill":
return f"Besiegst {monster}!"
elif action == "loot":
return f"Durchwühlst die Überreste von {monster}..."
else:
return f"Greifst {monster} an..."
# Pre-generate cache to avoid calling LLM too often
class NameCache:
def __init__(self):
self.monsters = []
self.items = {}
self.quests = []
self.locations = []
self._initial_fill()
def _initial_fill(self):
"""Füllt den Cache initial mit Fallback-Werten."""
self.monsters = FALLBACK_MONSTERS.copy()
self.quests = FALLBACK_QUESTS.copy()
self.locations = FALLBACK_LOCATIONS.copy()
for itype, names in FALLBACK_ITEMS.items():
self.items[itype] = names.copy()
def get_monster(self, level: int, location: str = "") -> str:
if not self.monsters:
self._initial_fill()
name = self.monsters.pop(random.randint(0, len(self.monsters) - 1))
return name
def get_item(self, item_type: str, level: int, rarity: str = "gewöhnlich") -> str:
names = self.items.get(item_type, FALLBACK_ITEMS.get(item_type, FALLBACK_ITEMS["junk"]))
if not names:
names = FALLBACK_ITEMS.get(item_type, FALLBACK_ITEMS["junk"])
return names[random.randint(0, len(names) - 1)]
def get_quest(self, level: int) -> str:
if not self.quests:
self._initial_fill()
return self.quests.pop(random.randint(0, len(self.quests) - 1))
def get_location(self) -> str:
if not self.locations:
self._initial_fill()
return self.locations.pop(random.randint(0, len(self.locations) - 1))
def refill_async(self):
"""Füllt den Cache im Hintergrund mit LLM-generierten Namen auf."""
try:
for _ in range(3):
name = generate_monster_name(1)
if name and name not in self.monsters:
self.monsters.append(name)
for _ in range(2):
name = generate_quest_name(1)
if name and name not in self.quests:
self.quests.append(name)
for _ in range(2):
name = generate_location_name()
if name and name not in self.locations:
self.locations.append(name)
for itype in ["weapon", "armor", "helmet", "boots"]:
name = generate_item_name(itype, 1)
if name:
self.items.setdefault(itype, []).append(name)
except:
pass
+243
View File
@@ -0,0 +1,243 @@
"""Fortschritts-Quest — Automatisches RPG mit lokalem LLM."""
import os
import sys
import time
import random
import signal
# Add src to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rich.console import Console
from rich.live import Live
from rich.layout import Layout
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich.progress import Progress, BarColumn, TextColumn
from rich.align import Align
from rich import box
from character import Character, RACES, CLASSES
from engine import GameEngine
from llm import NameCache
console = Console()
SAVE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "saves")
os.makedirs(SAVE_DIR, exist_ok=True)
BANNER = """
[bold magenta]╔═══════════════════════════════════════════════════════════╗
║ FORTSCHRITTS-QUEST — Das automatische RPG ║
"Wo du nichts tust und alles passiert"
╚═══════════════════════════════════════════════════════════╝[/]
"""
def create_character():
"""Character-Erstellung mit Zufallsoption."""
console.clear()
console.print(BANNER)
console.print("[bold]Character-Erstellung[/]\n")
# Name
name = console.input("[cyan]Name deines Helden (oder Enter für Zufall): [/]").strip()
if not name:
name = random.choice(["Günther", "Brigitte", "Klaus-Dieter", "Waltraud", "Herbert", "Sieglinde", "Wolfgang", "Margot"])
console.print(f"[dim]Zufälliger Name: {name}[/]")
# Race
console.print("\n[bold]Verfügbare Rassen:[/]")
for i, r in enumerate(RACES):
console.print(f" [cyan]{i+1}[/] — {r}")
race_choice = console.input(f"\n[cyan]Rasse wählen (1-{len(RACES)}, Enter=Zufall): [/]").strip()
if race_choice.isdigit() and 1 <= int(race_choice) <= len(RACES):
race = RACES[int(race_choice) - 1]
else:
race = random.choice(RACES)
console.print(f"[dim]Zufällige Rasse: {race}[/]")
# Class
console.print("\n[bold]Verfügbare Klassen:[/]")
for i, c in enumerate(CLASSES):
console.print(f" [cyan]{i+1}[/] — {c}")
class_choice = console.input(f"\n[cyan]Klasse wählen (1-{len(CLASSES)}, Enter=Zufall): [/]").strip()
if class_choice.isdigit() and 1 <= int(class_choice) <= len(CLASSES):
char_class = CLASSES[int(class_choice) - 1]
else:
char_class = random.choice(CLASSES)
console.print(f"[dim]Zufällige Klasse: {char_class}[/]")
char = Character(name, race, char_class)
# Show summary
console.print(f"\n[bold green]✅ Character erstellt![/]")
console.print(f" Name: [bold]{char.name}[/]")
console.print(f" Rasse: {char.race}")
console.print(f" Klasse: {char.char_class}")
console.print(f" Stufe: {char.level}")
console.print(f" HP: {char.hp}/{char.max_hp} | MP: {char.mp}/{char.max_mp}")
console.print(f" STR: {char.str} | DEX: {char.dex} | INT: {char.int} | VIT: {char.vit} | LCK: {char.lck}")
console.print(f" Gold: {char.gold}")
console.input("\n[dim]Enter drücken um zu starten...[/]")
return char
def render_game(engine: GameEngine) -> Panel:
"""Rendert das komplette Spiel-UI."""
c = engine.character
# === Character Panel ===
char_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1))
char_table.add_column("Stat", style="cyan", width=8)
char_table.add_column("Value", style="bold")
char_table.add_row("Name", f"{c.name}")
char_table.add_row("Rasse", c.race)
char_table.add_row("Klasse", c.char_class)
char_table.add_row("Stufe", str(c.level))
char_table.add_row("XP", f"{c.xp}/{c.xp_to_next}")
char_table.add_row("HP", f"{c.hp}/{c.max_hp}")
char_table.add_row("MP", f"{c.mp}/{c.max_mp}")
char_table.add_row("Angriff", str(c.attack))
char_table.add_row("Vert.", str(c.defense))
char_table.add_row("Gold", f"{c.gold} 💰")
char_table.add_row("Kills", str(c.kills))
char_table.add_row("Tode", str(c.deaths))
char_table.add_row("Quests", str(c.quests_done))
# === Equipment Panel ===
equip_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1), title="[bold]Ausrüstung[/]")
equip_table.add_column("Slot", style="cyan", width=10)
equip_table.add_column("Item")
slot_names = {"weapon": "Waffe", "armor": "Rüstung", "helmet": "Helm", "boots": "Stiefel", "accessory": "Accessoire"}
for slot, name in slot_names.items():
item = c.equipped[slot]
equip_table.add_row(name, str(item) if item else "[dim]leer[/]")
# === Quest Panel ===
quest_text = Text()
quest_text.append(f"📜 {engine.current_quest}\n", style="cyan")
quest_text.append(f"Fortschritt: {engine.quest_progress}/{engine.quest_target}", style="yellow")
# === XP Bar ===
xp_pct = c.xp / c.xp_to_next
xp_bar_len = 20
xp_filled = int(xp_pct * xp_bar_len)
xp_bar = "" * xp_filled + "" * (xp_bar_len - xp_filled)
# HP Bar
hp_pct = c.hp / c.max_hp
hp_filled = int(hp_pct * xp_bar_len)
hp_bar = "" * hp_filled + "" * (xp_bar_len - hp_filled)
# === Log Panel ===
log_text = Text()
for entry in engine.log[-12:]:
if isinstance(entry, str):
log_text.append(entry + "\n")
# === Location ===
loc_text = f"🌍 {engine.current_location}"
# === Layout ===
layout = Layout()
# Top: Title bar
layout.split_column(
Layout(Panel(f"[bold magenta]FORTSCHRITTS-QUEST[/] — {c.name} der {c.race} {c.char_class}",
style="magenta"), size=3),
Layout(name="main"),
Layout(name="bottom", size=8),
)
# Main: left=char/equip, center=log, right=quest/stats
layout["main"].split_row(
Layout(Panel(char_table, title="[bold]Character[/]"), name="left", size=30),
Layout(Panel(log_text, title="[bold]Ereignis-Log[/]"), name="center"),
Layout(name="right", size=30),
)
layout["right"].split_column(
Layout(Panel(equip_table), name="equip"),
Layout(Panel(quest_text, title="[bold]Aktuelle Quest[/]"), name="quest"),
)
# Bottom: bars + location
bars = Text()
bars.append(f"HP [{hp_bar}] {c.hp}/{c.max_hp}\n", style="red")
bars.append(f"XP [{xp_bar}] {c.xp}/{c.xp_to_next}\n", style="green")
bars.append(f"{loc_text} | Schritte: {c.steps} | STR {c.str} DEX {c.dex} INT {c.int} VIT {c.vit} LCK {c.lck}")
layout["bottom"].update(Panel(bars, style="dim"))
return layout
def run_game(engine: GameEngine, save_path: str):
"""Haupt-Game-Loop mit Live-Rendering."""
auto_save_counter = 0
with Live(render_game(engine), console=console, refresh_per_second=2, screen=True) as live:
try:
while True:
# Game step
result = engine.step()
# Refill cache occasionally
if engine.character.steps % 5 == 0:
engine.cache.refill_async()
# Sell junk periodically
if engine.character.steps % 10 == 0:
engine._sell_junk()
# Auto-save every 50 steps
auto_save_counter += 1
if auto_save_counter >= 50:
engine.save(save_path)
auto_save_counter = 0
# Render
live.update(render_game(engine))
# Speed — slower for readability, faster at higher levels
speed = max(0.3, 1.5 - engine.character.level * 0.02)
time.sleep(speed)
except KeyboardInterrupt:
live.update(render_game(engine))
console.print("\n[bold yellow]Spiel pausiert.[/]")
engine.save(save_path)
console.print(f"[green]Spielstand gespeichert: {save_path}[/]")
console.print("[dim]Drücke Enter zum Beenden...[/]")
input()
def main():
console.clear()
console.print(BANNER)
save_path = os.path.join(SAVE_DIR, "savegame.json")
# Check for existing save
if os.path.exists(save_path):
choice = console.input("[cyan]Spielstand gefunden. Fortsetzen? (j/n): [/]").strip().lower()
if choice in ("j", "ja", "y", "yes", ""):
console.print("[green]Lade Spielstand...[/]")
engine = GameEngine.load(save_path)
console.print(f"[dim]Willkommen zurück, {engine.character.name} (Stufe {engine.character.level})![/]")
time.sleep(1)
run_game(engine, save_path)
return
# New game
char = create_character()
engine = GameEngine(char)
run_game(engine, save_path)
if __name__ == "__main__":
main()