v2.0 — D&D 5e Regelwerk mit Würfel-Fenster

Vollständiges D&D 5e Combat-System:
- 6 Ability Scores (STR/DEX/CON/INT/WIS/CHA), 4d6 drop lowest
- 10 D&D Klassen (Barde bis Hexenmeister), 10 Rassen mit Boni
- d20 Attack Rolls vs AC, Damage Dice, Critical Hit (nat 20), Critical Miss (nat 1)
- Saving Throws (CON bei Tod), Proficiency Bonus (+2/+3/+4)
- D&D 5e XP-Tabelle (300/900/2700/...)
- Hit Dice, Level-Up mit HP gain + ASI alle 4 Level
- AC = 10 + DEX + Armor + Shield
- Spell Slots (vereinfacht)

UI:
- Würfel-Fenster Panel zeigt jeden d20-Wurf
- Fantasy-Theme: Schattengoblin, Höhlentroll, Knochendrache etc.
- Quest-Abschluss mit Reise + 3s Pause
- Save/Load mit dice_log
This commit is contained in:
arch_agent
2026-07-25 23:01:49 +02:00
parent 5337910b34
commit f6105c33b3
5 changed files with 703 additions and 611 deletions
-162
View File
@@ -1,162 +0,0 @@
"""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}"
+288
View File
@@ -0,0 +1,288 @@
"""D&D 5e Regelwerk: Stats, Würfel, Combat, Classes."""
import random
# === D&D 5e dice ===
def roll(sides: int, count: int = 1, modifier: int = 0, drop_lowest: int = 0) -> list:
"""Wirft Würfel und gibt [einzelwürfe, gesamt] zurück."""
rolls = [random.randint(1, sides) for _ in range(count)]
if drop_lowest > 0 and drop_lowest < count:
sorted_rolls = sorted(rolls, reverse=True)
rolls = sorted_rolls[:count - drop_lowest]
total = sum(rolls) + modifier
return {"rolls": rolls, "modifier": modifier, "total": total}
def roll_d20(modifier: int = 0, advantage: bool = False, disadvantage: bool = False) -> dict:
"""d20-Wurf mit optional Advantage/Disadvantage."""
if advantage:
r1 = random.randint(1, 20)
r2 = random.randint(1, 20)
result = max(r1, r2)
return {"rolls": [r1, r2], "modifier": modifier, "total": result + modifier, "advantage": True, "raw": result}
elif disadvantage:
r1 = random.randint(1, 20)
r2 = random.randint(1, 20)
result = min(r1, r2)
return {"rolls": [r1, r2], "modifier": modifier, "total": result + modifier, "disadvantage": True, "raw": result}
else:
raw = random.randint(1, 20)
return {"rolls": [raw], "modifier": modifier, "total": raw + modifier, "raw": raw}
# === D&D 5e Ability Scores ===
def roll_stats() -> dict:
"""4d6 drop lowest, 6 mal für STR/DEX/CON/INT/WIS/CHA."""
stats = {}
for ability in ["STR", "DEX", "CON", "INT", "WIS", "CHA"]:
result = roll(6, 4, drop_lowest=1)
stats[ability] = result["total"]
return stats
def modifier(score: int) -> int:
"""D&D 5e modifier: (score - 10) // 2."""
return (score - 10) // 2
# === D&D 5e Classes ===
DND_CLASSES = {
"Barde": {
"hit_die": 8, "primary": "CHA", "saves": ["DEX", "CHA"],
"weapon_dice": "1d8", "armor_prof": "leicht",
},
"Kleriker": {
"hit_die": 8, "primary": "WIS", "saves": ["WIS", "CHA"],
"weapon_dice": "1d8", "armor_prof": "mittel",
},
"Druide": {
"hit_die": 8, "primary": "WIS", "saves": ["INT", "WIS"],
"weapon_dice": "1d6", "armor_prof": "mittel",
},
"Kämpfer": {
"hit_die": 10, "primary": "STR", "saves": ["STR", "CON"],
"weapon_dice": "1d8", "armor_prof": "schwer",
},
"Magier": {
"hit_die": 6, "primary": "INT", "saves": ["INT", "WIS"],
"weapon_dice": "1d6", "armor_prof": "keine",
},
"Mönch": {
"hit_die": 8, "primary": "DEX", "saves": ["STR", "DEX"],
"weapon_dice": "1d6", "armor_prof": "keine",
},
"Paladin": {
"hit_die": 10, "primary": "CHA", "saves": ["WIS", "CHA"],
"weapon_dice": "1d8", "armor_prof": "schwer",
},
"Schurke": {
"hit_die": 8, "primary": "DEX", "saves": ["DEX", "INT"],
"weapon_dice": "1d6", "armor_prof": "leicht",
},
"Waldläufer": {
"hit_die": 10, "primary": "DEX", "saves": ["STR", "DEX"],
"weapon_dice": "1d8", "armor_prof": "mittel",
},
"Hexenmeister": {
"hit_die": 8, "primary": "CHA", "saves": ["WIS", "CHA"],
"weapon_dice": "1d6", "armor_prof": "leicht",
},
}
DND_RACES = {
"Mensch": {"boni": {"STR": 1, "DEX": 1, "CON": 1, "INT": 1, "WIS": 1, "CHA": 1}, "speed": 30},
"Elf": {"boni": {"DEX": 2}, "speed": 30},
"Zwerg": {"boni": {"CON": 2}, "speed": 25},
"Halbling": {"boni": {"DEX": 2}, "speed": 25},
"Gnom": {"boni": {"INT": 2}, "speed": 25},
"Halb-Elf": {"boni": {"CHA": 2}, "speed": 30},
"Halb-Ork": {"boni": {"STR": 2, "CON": 1}, "speed": 30},
"Tiefling": {"boni": {"INT": 1, "CHA": 2}, "speed": 30},
"Drachenblütiger": {"boni": {"STR": 2, "CHA": 1}, "speed": 30},
"Dunkelelf": {"boni": {"DEX": 2, "CHA": 1}, "speed": 30},
}
# === D&D 5e Character Stats ===
class DnDCharacter:
def __init__(self, name: str, race: str, dnd_class: str):
self.name = name
self.race = race
self.dnd_class = dnd_class
# Roll base stats
base = roll_stats()
race_boni = DND_RACES.get(race, {}).get("boni", {})
self.stats = {k: v + race_boni.get(k, 0) for k, v in base.items()}
self.level = 1
self.xp = 0
class_data = DND_CLASSES.get(dnd_class, DND_CLASSES["Kämpfer"])
self.hit_die = class_data["hit_die"]
self.save_prof = class_data["saves"]
self.weapon_dice = class_data["weapon_dice"]
# HP = max(hit_die) + CON mod at level 1, + hit_die/2 + CON mod per level
con_mod = modifier(self.stats["CON"])
self.max_hp = self.hit_die + con_mod
self.hp = self.max_hp
# AC = 10 + DEX mod (light armor / no armor)
dex_mod = modifier(self.stats["DEX"])
self.base_ac = 10 + dex_mod
self.armor_bonus = 0 # from equipment
self.shield_bonus = 0
# Proficiency bonus (D&D 5e: +2 at 1-4, +3 at 5-8, etc.)
self.prof_bonus = self._calc_prof_bonus()
# Spell slots (simplified)
self.spell_slots = self._calc_spell_slots()
# Equipment
self.equipped = {"weapon": None, "armor": None, "shield": None, "helmet": None, "boots": None, "accessory": None}
self.inventory = []
self.gold = roll(6, 3)["total"] * 10 # Starting gold 3d6 * 10
# Stats tracking
self.kills = 0
self.deaths = 0
self.quests_done = 0
self.steps = 0
self.crit_hits = 0
self.crit_misses = 0
self.nat_20s = 0
@property
def ac(self) -> int:
return self.base_ac + self.armor_bonus + self.shield_bonus
def _calc_prof_bonus(self) -> int:
return 2 + ((self.level - 1) // 4)
def _calc_spell_slots(self) -> int:
if self.dnd_class in ("Magier", "Hexenmeister", "Barde", "Kleriker", "Druide"):
return max(0, self.level) # simplified: 1 slot per level
return 0
@property
def attack_bonus(self) -> int:
"""Attack roll modifier: STR/DEX mod + proficiency."""
class_data = DND_CLASSES.get(self.dnd_class, {})
primary = class_data.get("primary", "STR")
if primary in ("DEX",):
ability_mod = modifier(self.stats["DEX"])
else:
ability_mod = modifier(self.stats["STR"])
weapon_bonus = self.equipped["weapon"].power if self.equipped["weapon"] else 0
return ability_mod + self.prof_bonus + weapon_bonus
@property
def damage_bonus(self) -> int:
"""Damage modifier: STR/DEX mod + weapon bonus."""
class_data = DND_CLASSES.get(self.dnd_class, {})
primary = class_data.get("primary", "STR")
if primary == "DEX":
ability_mod = modifier(self.stats["DEX"])
else:
ability_mod = modifier(self.stats["STR"])
return ability_mod
@property
def xp_to_next(self) -> int:
"""D&D 5e XP thresholds."""
thresholds = [0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000, 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000]
if self.level < len(thresholds):
return thresholds[self.level]
return 999999
def saving_throw(self, ability: str) -> dict:
"""Saving Throw: d20 + ability mod + prof (if proficient)."""
mod = modifier(self.stats[ability])
prof = self.prof_bonus if ability in self.save_prof else 0
total_mod = mod + prof
result = roll_d20(total_mod)
result["ability"] = ability
result["modifier"] = total_mod
result["success"] = result["total"] >= 10 # DC 10 default
return result
def level_up(self):
self.level += 1
con_mod = modifier(self.stats["CON"])
hp_gain = (self.hit_die // 2) + 1 + con_mod
self.max_hp += hp_gain
self.hp = self.max_hp
self.prof_bonus = self._calc_prof_bonus()
self.spell_slots = self._calc_spell_slots()
# ASI: +2 to one stat every 4 levels
if self.level % 4 == 0:
best_stat = max(self.stats, key=lambda k: self.stats[k])
self.stats[best_stat] += 2
return hp_gain
def gain_xp(self, amount: int) -> bool:
self.xp += amount
if self.xp >= self.xp_to_next:
self.xp -= self.xp_to_next
self.level_up()
return True
return False
def is_alive(self) -> bool:
return self.hp > 0
def heal(self):
self.hp = self.max_hp
def to_dict(self) -> dict:
return {
"name": self.name, "race": self.race, "dnd_class": self.dnd_class,
"stats": self.stats, "level": self.level, "xp": self.xp,
"hit_die": self.hit_die, "save_prof": self.save_prof,
"weapon_dice": self.weapon_dice,
"max_hp": self.max_hp, "hp": self.hp,
"base_ac": self.base_ac, "armor_bonus": self.armor_bonus, "shield_bonus": self.shield_bonus,
"prof_bonus": self.prof_bonus, "spell_slots": self.spell_slots,
"equipped": {k: v.to_dict() if v else None for k, v in self.equipped.items()},
"inventory": [i.to_dict() for i in self.inventory],
"gold": self.gold,
"kills": self.kills, "deaths": self.deaths, "quests_done": self.quests_done,
"steps": self.steps, "crit_hits": self.crit_hits, "crit_misses": self.crit_misses, "nat_20s": self.nat_20s,
}
@classmethod
def from_dict(cls, d: dict) -> "DnDCharacter":
c = cls(d["name"], d["race"], d["dnd_class"])
c.stats = d["stats"]
c.level = d["level"]; c.xp = d["xp"]
c.hit_die = d["hit_die"]; c.save_prof = d["save_prof"]
c.weapon_dice = d["weapon_dice"]
c.max_hp = d["max_hp"]; c.hp = d["hp"]
c.base_ac = d["base_ac"]; c.armor_bonus = d["armor_bonus"]; c.shield_bonus = d["shield_bonus"]
c.prof_bonus = d["prof_bonus"]; c.spell_slots = d["spell_slots"]
c.equipped = {k: Item.from_dict(v) if v else None for k, v in d.get("equipped", {}).items()}
c.inventory = [Item.from_dict(i) for i in d.get("inventory", [])]
c.gold = d["gold"]
c.kills = d["kills"]; c.deaths = d["deaths"]; c.quests_done = d["quests_done"]
c.steps = d["steps"]; c.crit_hits = d["crit_hits"]; c.crit_misses = d["crit_misses"]; c.nat_20s = d["nat_20s"]
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
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} (+{self.power}, {self.value}G){suffix}"
+318 -132
View File
@@ -1,103 +1,276 @@
"""Game-Engine: Auto-Progress, Kämpfe, Loot, Quests, Shop."""
"""Game-Engine mit D&D 5e Combat-System."""
import random
import time
from character import Character, Item, RACES, CLASSES
from llm import NameCache
from dnd import DnDCharacter, Item, DND_RACES, DND_CLASSES, roll, roll_d20, modifier
class Monster:
def __init__(self, name: str, level: int, hp: int, attack: int, defense: int, xp: int, gold: int):
# Fantasy Fallbacks
FALLBACK_MONSTERS = [
"Schattengoblin", "Höhlentroll", "Knochendrache", "Verfluchter Ritter",
"Sumpfhexe", "Frostwolf", "Steingolem", "Untoter Magier",
"Riesenspinne", "Düsterschurke", "Feuerdämon", "Eisriese",
"Waldgnomenräuber", "Dunkler Schamane", "Skelettkrieger", "Vergifter-Naga",
"Höllenjagd-Hund", "Sturmschlange", "Ruinengolem", "Schattenassassine",
]
FALLBACK_ITEMS = {
"weapon": ["Verrostetes Kurzschwert", "Eiche-Kampfstab", "Zwergenaxt", "Elfenbogen"],
"armor": ["Lederrüstung", "Kettenhemd", "Plattenpanzer", "Magierrobe"],
"shield": ["Holzschild", "Eisenschild", "Drachenschuppenschild"],
"helmet": ["Ledermütze", "Sturmmhelm", "Eisenhelm", "Helm der Weisheit"],
"boots": ["Lederstiefel", "Wanderstiefel", "Stiefel der Geschwindigkeit"],
"accessory": ["Ring des Schutzes", "Amulett der Macht", "Zauberhandschuh"],
"junk": ["Verrosteter Schlüssel", "Alte Schriftrolle", "Knochenstück", "Glasflasche"],
}
FALLBACK_QUESTS = [
"Befreie das Dorf von der Schattengoblin-Plage",
"Finde das verlorene Schwert des Königs",
"Besiege den Drachen der Dunklen Höhle",
"Sammle 10 magische Kristalle",
"Begleite den Händler sicher durch den Wald",
"Reinige den alten Tempel von Untoten",
"Befreie die gefangene Prinzessin",
"Finde den verschwundenen Magier",
]
FALLBACK_LOCATIONS = [
"Die Knochenkavernen von Düstermoor",
"Der Verfluchte Wald von Schattental",
"Die Eisfeste von Nordwind",
"Die Drachenhöhle am Feuerberg",
"Die Ruinen von Alt-Eisenberg",
"Der Sumpf der Verlorenen Seelen",
"Das Labyrinth der Schatten",
"Die Unterwelt von Tiefenhall",
]
class DnDMonster:
"""D&D 5e Monster mit echten Stats."""
def __init__(self, name: str, level: int, char_level: int):
self.name = name
self.level = level
self.hp = hp
self.attack = attack
self.defense = defense
self.xp = xp
self.gold = gold
self.challenge_rating = max(0.25, level / 4)
def is_alive(self):
# Stats basierend auf Challenge Rating
self.ac = 10 + level + random.randint(0, 3)
self.hp = max(1, int((level * 8) + random.randint(0, 10)))
self.max_hp = self.hp
# Attack: d20 + prof + STR/DEX
self.attack_mod = level // 2 + 2 + random.randint(0, 2)
# Damage: weapon dice + mod
self.damage_dice = random.choice(["1d6", "1d8", "1d10", "2d6", "1d12"])
self.damage_mod = max(0, level // 3)
# XP and Gold (D&D 5e CR table, simplified)
cr_xp = {0.25: 50, 0.5: 100, 1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800,
6: 2300, 7: 2900, 8: 3900, 9: 5000, 10: 5900}
cr_key = min(self.challenge_rating, 10) if self.challenge_rating >= 1 else 0.25
self.xp = cr_xp.get(cr_key, int(100 * self.challenge_rating))
self.gold = max(1, int(level * random.uniform(1, 5)))
# Saving throws
self.saves = {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}
@property
def is_alive(self) -> bool:
return self.hp > 0
def attack(self, target_ac: int, dice_log: list) -> dict:
"""Monster greift an mit d20."""
atk_roll = roll_d20(self.attack_mod)
dice_log.append(f"[red]🎲 {self.name} greift an: d20{atk_roll['rolls']}{'+' if self.attack_mod >= 0 else ''}{self.attack_mod} = {atk_roll['total']} vs AC {target_ac}[/]")
hit = atk_roll["total"] >= target_ac
crit = atk_roll["raw"] == 20
fumble = atk_roll["raw"] == 1
if fumble:
dice_log.append(f"[red]💀 Kritischer Fehlschlag! {self.name} stolpert![/]")
return {"hit": False, "damage": 0, "crit": False, "fumble": True}
if not hit:
dice_log.append(f"[yellow]💨 {self.name} verfehlt! (AC {target_ac})[/]")
return {"hit": False, "damage": 0, "crit": False, "fumble": False}
# Damage roll
dice_str = self.damage_dice
count, sides = map(int, dice_str.split("d"))
dmg_roll = roll(sides, count, self.damage_mod)
if crit:
# Double dice on crit
extra = roll(sides, count, 0)
total_dmg = dmg_roll["total"] + extra["total"] - self.damage_mod
dice_log.append(f"[bold red]💥 KRITISCH! {dice_str}+{self.damage_mod} = {dmg_roll['total']} + {extra['total']} (crit) = {total_dmg} Schaden![/]")
else:
total_dmg = dmg_roll["total"]
dice_log.append(f"[red]⚔️ {self.name} trifft: {dice_str}+{self.damage_mod} = {total_dmg} Schaden[/]")
return {"hit": True, "damage": total_dmg, "crit": crit, "fumble": False}
class DiceLog:
"""Verwaltet den Würfel-Log für die UI."""
def __init__(self, max_entries: int = 25):
self.entries = []
self.max_entries = max_entries
def add(self, msg: str):
self.entries.insert(0, msg)
if len(self.entries) > self.max_entries:
self.entries = self.entries[:self.max_entries]
def clear(self):
self.entries = []
def get_all(self) -> list:
return self.entries
class GameEngine:
def __init__(self, character: Character):
def __init__(self, character: DnDCharacter):
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.dice_log = DiceLog(max_entries=25)
self.event_log = []
self.max_event_log = 20
self.loot_log = []
self.max_loot_log = 20
self.current_location = random.choice(FALLBACK_LOCATIONS)
self.current_quest = random.choice(FALLBACK_QUESTS)
self.quest_progress = 0
self.quest_target = random.randint(3, 8)
self.log = []
self.max_log = 30
self.loot_log = []
self.max_loot_log = 30
self._fill_cache()
def _fill_cache(self):
"""Füllt Cache initial."""
pass # LLM wird asynchron in refill_async geladen
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]
self.event_log.insert(0, msg)
if len(self.event_log) > self.max_event_log:
self.event_log = self.event_log[:self.max_event_log]
def _add_loot_log(self, msg: str):
self.loot_log.insert(0, msg)
if len(self.loot_log) > self.max_loot_log:
self.loot_log = self.loot_log[:self.max_loot_log]
def _spawn_monster(self) -> Monster:
name = self.cache.get_monster(self.character.level, self.current_location)
def _spawn_monster(self) -> DnDMonster:
name = random.choice(FALLBACK_MONSTERS)
# Add adjective sometimes
if random.randint(1, 3) == 1:
adj = random.choice(["Wütender", "Verirrter", "Verfluchter", "Hungriger", "Müder", "Brüllender"])
name = f"{adj} {name}"
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)
return DnDMonster(name, level, self.character.level)
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]
def _generate_loot(self, monster: DnDMonster) -> Item:
item_types = ["weapon", "armor", "shield", "helmet", "boots", "accessory", "junk"]
weights = [15, 15, 10, 10, 8, 8, 34]
item_type = random.choices(item_types, weights=weights)[0]
# Rarity based on luck and level
roll = random.randint(1, 100) + self.character.lck
if roll > 95:
# Rarity
roll_val = random.randint(1, 100) + (modifier(self.character.stats["CHA"]) if "CHA" in self.character.stats else 0)
if roll_val > 95:
rarity = "legendär"
elif roll > 80:
elif roll_val > 80:
rarity = "episch"
elif roll > 60:
elif roll_val > 60:
rarity = "selten"
else:
rarity = "gewöhnlich"
name = self.cache.get_item(item_type, self.character.level, rarity)
name = random.choice(FALLBACK_ITEMS.get(item_type, FALLBACK_ITEMS["junk"]))
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))
power = max(1, int(self.character.level * mult + random.randint(0, 2)))
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
def _player_attack(self, monster: DnDMonster) -> dict:
"""Spieler greift an — D&D 5e Attack Roll."""
atk_mod = self.character.attack_bonus
atk_roll = roll_d20(atk_mod)
self.dice_log.add(f"[cyan]🎲 Du greifst an: d20{atk_roll['rolls']}{'+' if atk_mod >= 0 else ''}{atk_mod} = {atk_roll['total']} vs AC {monster.ac}[/]")
if not monster.is_alive():
# Track nat 20
if atk_roll["raw"] == 20:
self.character.nat_20s += 1
crit = atk_roll["raw"] == 20
fumble = atk_roll["raw"] == 1
if fumble:
self.character.crit_misses += 1
self.dice_log.add(f"[red]💀 Natürliche 1! Kritischer Fehlschlag![/]")
return {"hit": False, "damage": 0, "crit": False, "fumble": True}
hit = atk_roll["total"] >= monster.ac
if not hit:
self.dice_log.add(f"[yellow]💨 Du verfehlst! ({atk_roll['total']} < AC {monster.ac})[/]")
return {"hit": False, "damage": 0, "crit": False, "fumble": False}
if crit:
self.character.crit_hits += 1
# Damage roll
dice_str = self.character.weapon_dice
count, sides = map(int, dice_str.split("d"))
dmg_mod = self.character.damage_bonus
weapon_bonus = self.character.equipped["weapon"].power if self.character.equipped["weapon"] else 0
total_mod = dmg_mod + weapon_bonus
dmg_roll = roll(sides, count, total_mod)
if crit:
# Double dice on crit (D&D 5e)
extra = roll(sides, count, 0)
total_dmg = dmg_roll["total"] + extra["total"]
self.dice_log.add(f"[bold green]💥 KRITISCHER TREFFER! {dice_str}+{total_mod} = {dmg_roll['total']} + {extra['total']} (crit) = {total_dmg} Schaden![/]")
else:
total_dmg = dmg_roll["total"]
self.dice_log.add(f"[green]⚔️ Du triffst! {dice_str}+{total_mod} = {total_dmg} Schaden[/]")
return {"hit": True, "damage": total_dmg, "crit": crit, "fumble": False}
def _combat(self, monster: DnDMonster) -> dict:
"""D&D 5e Combat: abwechselnde Runden."""
rounds = 0
self.dice_log.add(f"[bold]━━━ Kampf: {self.character.name} vs {monster.name} (AC {monster.ac}, HP {monster.hp}) ━━━[/]")
while monster.is_alive and self.character.is_alive:
rounds += 1
# Player attacks (initiative: DEX check)
result = self._player_attack(monster)
if result["hit"]:
monster.hp = max(0, monster.hp - result["damage"])
if not monster.is_alive:
self.dice_log.add(f"[bold green]☠️ {monster.name} besiegt![/]")
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():
if monster.is_alive:
m_result = monster.attack(self.character.ac, self.dice_log.entries)
self.dice_log.entries = self.dice_log.entries # update ref
if m_result["hit"]:
self.character.hp = max(0, self.character.hp - m_result["damage"])
if not self.character.is_alive:
self.dice_log.add(f"[bold red]💀 Du wurdest besiegt![/]")
return {"result": "death", "rounds": rounds, "monster": monster.name}
self.dice_log.add(f"[dim]── Runde {rounds} ── HP: {self.character.hp}/{self.character.max_hp} | Monster: {monster.hp}/{monster.max_hp} ──[/]")
return {"result": "victory", "rounds": rounds, "monster": monster.name}
def _complete_quest(self):
@@ -106,90 +279,29 @@ class GameEngine:
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}[/]")
self._add_log(f"[dim]Belohnung: +{quest_gold} Gold, +{quest_xp} XP[/]")
self.dice_log.add(f"[green]📜 Quest-Belohnung: +{quest_gold}G, +{quest_xp}XP[/]")
if leveled:
self._add_log(f"[bold yellow]⬆️ Stufe {self.character.level} erreicht![/]")
self.dice_log.add(f"[bold yellow]⬆️ LEVEL UP! Stufe {self.character.level}[/]")
self.character.heal()
# Travel to new location before new quest
self._add_log(f"[blue]🌍 Du machst dich auf den Weg...[/]")
self.current_location = self.cache.get_location()
self.current_location = random.choice(FALLBACK_LOCATIONS)
self._add_log(f"[blue]🌍 Ankunft in: {self.current_location}[/]")
# New quest
self.current_quest = self.cache.get_quest(self.character.level)
self.current_quest = random.choice(FALLBACK_QUESTS)
self.quest_progress = 0
self.quest_target = random.randint(3, 8)
self._add_log(f"[cyan]📜 Neue Quest: {self.current_quest}[/]")
self._add_log(f"[dim]Ziel: Besiege {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_loot_log(f"[magenta]📦 {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![/]", True)) # True = pause flag
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"):
if item.item_type in ("weapon", "armor", "shield", "helmet", "boots", "accessory"):
slot = item.item_type
if slot:
@@ -204,7 +316,6 @@ class GameEngine:
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:
@@ -212,7 +323,6 @@ class GameEngine:
self._add_loot_log(f"[dim]💰 Verkauft: {item.name} (+{item.value}G)[/]")
def _sell_junk(self):
"""Verkauft alle Schrott-Items."""
sold = 0
total_gold = 0
for item in self.character.inventory[:]:
@@ -224,6 +334,80 @@ class GameEngine:
if sold > 0:
self._add_loot_log(f"[dim]💰 {sold} Schrott verkauft (+{total_gold}G)[/]")
def step(self) -> dict:
self.character.steps += 1
events = []
# Random travel
if random.randint(1, 50) == 1:
self.current_location = random.choice(FALLBACK_LOCATIONS)
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} (AC {monster.ac}, HP {monster.hp}) 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)
self.dice_log.add(f"[green]📊 +{monster.xp} XP, +{monster.gold} Gold[/]")
events.append(("reward", 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.dice_log.add(f"[bold yellow]⬆️ LEVEL UP! Stufe {self.character.level}[/]")
self.character.heal()
events.append(("levelup", f"[bold yellow]⬆️ STUFE {self.character.level}![/]"))
# Loot
if random.randint(1, 100) <= 70:
item = self._generate_loot(monster)
self._add_loot_log(f"[magenta]📦 {item}[/]")
events.append(("loot", f"[magenta]📦 {item}[/]", item))
self._auto_equip(item)
# 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![/]", True))
elif result["result"] == "death":
self.character.deaths += 1
# Death saving throw
save = self.character.saving_throw("CON")
self.dice_log.add(f"[yellow]🎲 Todesschaden-Save: d20{save['rolls']}+{save['modifier']} = {save['total']} (DC 10)[/]")
if save["success"]:
self.dice_log.add(f"[green]💚 Du stabilisierst dich![/]")
self._add_log(f"[yellow]💀 {monster.name} besiegt dich, aber du überlebst![/]")
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.[/]")
else:
self.dice_log.add(f"[red]💀 Rettungswurf fehlgeschlagen![/]")
self._add_log(f"[bold red]💀 Von {monster.name} besiegt! Respawn...[/]")
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.[/]")
events.append(("death", f"[bold red]💀 Niederlage gegen {monster.name}![/]"))
return {"events": events}
def get_state(self) -> dict:
return {
"character": self.character.to_dict(),
@@ -231,8 +415,9 @@ class GameEngine:
"current_quest": self.current_quest,
"quest_progress": self.quest_progress,
"quest_target": self.quest_target,
"log": self.log,
"event_log": self.event_log,
"loot_log": self.loot_log,
"dice_log": self.dice_log.get_all(),
}
def save(self, path: str):
@@ -245,12 +430,13 @@ class GameEngine:
import json
with open(path) as f:
d = json.load(f)
char = Character.from_dict(d["character"])
char = DnDCharacter.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", [])
engine.event_log = d.get("event_log", [])
engine.loot_log = d.get("loot_log", [])
engine.dice_log.entries = d.get("dice_log", [])
return engine
-216
View File
@@ -1,216 +0,0 @@
"""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
+92 -96
View File
@@ -1,12 +1,10 @@
"""Fortschritts-Quest — Automatisches RPG mit lokalem LLM."""
"""Fortschritts-Quest — D&D 5e Automatisches RPG."""
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
@@ -15,13 +13,10 @@ 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 dnd import DnDCharacter, DND_RACES, DND_CLASSES, roll_stats, modifier
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")
@@ -29,92 +24,102 @@ os.makedirs(SAVE_DIR, exist_ok=True)
BANNER = """
[bold magenta]╔═══════════════════════════════════════════════════════════╗
FORTSCHRITTS-QUEST — Das automatische RPG ║
"Wo du nichts tust und alles passiert"
║ FORTSCHRITTS-QUEST — D&D 5e Automatisches RPG
"Wo du nichts tust und alles passiert — mit Würfeln"
╚═══════════════════════════════════════════════════════════╝[/]
"""
def create_character():
"""Character-Erstellung mit Zufallsoption."""
console.clear()
console.print(BANNER)
console.print("[bold]Character-Erstellung[/]\n")
console.print("[bold]Character-Erstellung (D&D 5e)[/]\n")
# Name
name = console.input("[cyan]Name deines Helden (oder Enter für Zufall): [/]").strip()
name = console.input("[cyan]Name deines Helden (Enter=Zufall): [/]").strip()
if not name:
name = random.choice(["Günther", "Brigitte", "Klaus-Dieter", "Waltraud", "Herbert", "Sieglinde", "Wolfgang", "Margot"])
name = random.choice(["Aldric", "Lyanna", "Thorin", "Seraphina", "Gunnar", "Morgaine", "Kael", "Brienna"])
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]
for i, r in enumerate(DND_RACES):
boni = DND_RACES[r]["boni"]
bonus_str = ", ".join(f"+{v} {k}" for k, v in boni.items())
console.print(f" [cyan]{i+1}[/] — {r} ([dim]{bonus_str}[/])")
race_choice = console.input(f"\n[cyan]Rasse (1-{len(DND_RACES)}, Enter=Zufall): [/]").strip()
if race_choice.isdigit() and 1 <= int(race_choice) <= len(DND_RACES):
race = list(DND_RACES.keys())[int(race_choice) - 1]
else:
race = random.choice(RACES)
race = random.choice(list(DND_RACES.keys()))
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]
for i, c in enumerate(DND_CLASSES):
hd = DND_CLASSES[c]["hit_die"]
primary = DND_CLASSES[c]["primary"]
console.print(f" [cyan]{i+1}[/] — {c} ([dim]HD d{hd}, primär {primary}[/])")
class_choice = console.input(f"\n[cyan]Klasse (1-{len(DND_CLASSES)}, Enter=Zufall): [/]").strip()
if class_choice.isdigit() and 1 <= int(class_choice) <= len(DND_CLASSES):
dnd_class = list(DND_CLASSES.keys())[int(class_choice) - 1]
else:
char_class = random.choice(CLASSES)
console.print(f"[dim]Zufällige Klasse: {char_class}[/]")
dnd_class = random.choice(list(DND_CLASSES.keys()))
console.print(f"[dim]Zufällige Klasse: {dnd_class}[/]")
char = Character(name, race, char_class)
char = DnDCharacter(name, race, dnd_class)
# Roll stats
console.print(f"\n[bold]📊 Stats werden gewürfelt (4d6 drop lowest)...[/]")
for ab in ["STR", "DEX", "CON", "INT", "WIS", "CHA"]:
score = char.stats[ab]
mod = modifier(score)
mod_str = f"+{mod}" if mod >= 0 else str(mod)
bar = "" * (score // 2)
console.print(f" [cyan]{ab}[/] {score:2d} {bar:<10} ({mod_str})")
# 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" Klasse: [bold]{char.dnd_class}[/] (Trefferwürfel d{char.hit_die})")
console.print(f" HP: {char.hp}/{char.max_hp} | AC: {char.ac} | Prof: +{char.prof_bonus}")
console.print(f" Angriff: +{char.attack_bonus} | Schaden: {char.weapon_dice}+{char.damage_bonus}")
console.print(f" Rettungswürfe: {', '.join(char.save_prof)}")
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."""
def render_game(engine: GameEngine) -> Layout:
c = engine.character
# === Character Panel ===
char_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1), expand=True)
char_table.add_column("Stat", style="cyan", width=10, no_wrap=True)
char_table.add_column("Stat", style="cyan", width=8, no_wrap=True)
char_table.add_column("Value", style="bold", no_wrap=False, overflow="fold")
char_table.add_row("Name", f"{c.name}")
char_table.add_row("Name", c.name)
char_table.add_row("Rasse", c.race)
char_table.add_row("Klasse", c.char_class)
char_table.add_row("Klasse", c.dnd_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("AC", str(c.ac))
char_table.add_row("Prof", f"+{c.prof_bonus}")
char_table.add_row("ATK", f"+{c.attack_bonus}")
char_table.add_row("DMG", f"{c.weapon_dice}+{c.damage_bonus}")
char_table.add_row("Gold", f"{c.gold}G")
# Stats row
stats_str = " ".join(f"{k}:{c.stats[k]}" for k in ["STR","DEX","CON","INT","WIS","CHA"])
char_table.add_row("Stats", stats_str)
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))
char_table.add_row("Crits", f"{c.crit_hits}{c.crit_misses} 🎯{c.nat_20s}")
# === Equipment Panel ===
equip_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1), title="[bold]Ausrüstung[/]", expand=True)
equip_table.add_column("Slot", style="cyan", width=10, no_wrap=True)
equip_table.add_column("Item", no_wrap=False, overflow="fold")
slot_names = {"weapon": "Waffe", "armor": "Rüstung", "helmet": "Helm", "boots": "Stiefel", "accessory": "Accessoir."}
slot_names = {"weapon": "Waffe", "armor": "Rüstung", "shield": "Schild", "helmet": "Helm", "boots": "Stiefel", "accessory": "Accessoir."}
for slot, name in slot_names.items():
item = c.equipped[slot]
item = c.equipped.get(slot)
equip_table.add_row(name, str(item) if item else "[dim]leer[/]")
# === Quest Panel ===
@@ -122,65 +127,69 @@ def render_game(engine: GameEngine) -> Panel:
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:
if isinstance(entry, str):
# === Event Log ===
event_text = Text()
for entry in engine.event_log:
t = Text.from_markup(entry)
log_text.append(t)
log_text.append("\n")
event_text.append(t)
event_text.append("\n")
# === Loot Log Panel ===
# === Loot Log ===
loot_text = Text()
for entry in engine.loot_log:
if isinstance(entry, str):
t = Text.from_markup(entry)
loot_text.append(t)
loot_text.append("\n")
# === Location ===
loc_text = f"🌍 {engine.current_location}"
# === Dice Log (Würfel-Fenster) ===
dice_text = Text()
for entry in engine.dice_log.get_all():
t = Text.from_markup(entry)
dice_text.append(t)
dice_text.append("\n")
# === HP/XP Bars ===
hp_pct = c.hp / c.max_hp
xp_pct = c.xp / c.xp_to_next if c.xp_to_next > 0 else 0
bar_len = 20
hp_bar = "" * int(hp_pct * bar_len) + "" * (bar_len - int(hp_pct * bar_len))
xp_bar = "" * int(xp_pct * bar_len) + "" * (bar_len - int(xp_pct * bar_len))
# === Layout ===
layout = Layout()
# Top: Title bar
layout.split_column(
Layout(Panel(f"[bold magenta]FORTSCHRITTS-QUEST[/] — {c.name} der {c.race} {c.char_class}",
Layout(Panel(f"[bold magenta]FORTSCHRITTS-QUEST D&D 5e[/] — {c.name}, {c.race} {c.dnd_class} (Stufe {c.level})",
style="magenta"), size=3),
Layout(name="main"),
Layout(name="bottom", size=8),
Layout(name="bottom", size=6),
)
# Main: left=char/equip, center=log, right=quest/stats
# Main: left=char, center=eventlog+dice, right=equip+loot+quest
layout["main"].split_row(
Layout(Panel(char_table, title="[bold]Character[/]"), name="left", size=35),
Layout(Panel(log_text, title="[bold]Ereignis-Log[/]"), name="center"),
Layout(name="center"),
Layout(name="right", size=40),
)
# Center: event log on top, dice log on bottom
layout["center"].split_column(
Layout(Panel(event_text, title="[bold]Ereignis-Log[/]"), name="events"),
Layout(Panel(dice_text, title="[bold yellow]🎲 Würfel[/]"), name="dice", size=12),
)
# Right: equip, loot, quest
layout["right"].split_column(
Layout(Panel(equip_table), name="equip"),
Layout(Panel(loot_text, title="[bold]Loot-Log[/]"), name="loot"),
Layout(Panel(quest_text, title="[bold]Aktuelle Quest[/]"), name="quest"),
)
# Bottom: bars + location
# Bottom: bars
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}")
bars.append(f"HP [{hp_bar}] {c.hp}/{c.max_hp} ", style="red")
bars.append(f"XP [{xp_bar}] {c.xp}/{c.xp_to_next} ", style="green")
bars.append(f"🌍 {engine.current_location} ", style="blue")
bars.append(f"Schritte: {c.steps}", style="dim")
layout["bottom"].update(Panel(bars, style="dim"))
@@ -188,42 +197,31 @@ def render_game(engine: GameEngine) -> Panel:
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()
# Check for quest completion pause
quest_pause = False
for ev in result.get("events", []):
if isinstance(ev, tuple) and len(ev) > 2 and ev[0] == "quest_done" and ev[2]:
quest_pause = True
# 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)
speed = max(0.5, 2.0 - engine.character.level * 0.02)
if quest_pause:
time.sleep(2.0) # Pause after quest completion
time.sleep(3.0)
else:
time.sleep(speed)
@@ -232,7 +230,7 @@ def run_game(engine: GameEngine, save_path: str):
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...[/]")
console.print("[dim]Enter zum Beenden...[/]")
input()
@@ -242,7 +240,6 @@ def main():
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", ""):
@@ -253,7 +250,6 @@ def main():
run_game(engine, save_path)
return
# New game
char = create_character()
engine = GameEngine(char)
run_game(engine, save_path)