diff --git a/src/character.py b/src/character.py deleted file mode 100644 index a921a55..0000000 --- a/src/character.py +++ /dev/null @@ -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}" \ No newline at end of file diff --git a/src/dnd.py b/src/dnd.py new file mode 100644 index 0000000..c4a89e1 --- /dev/null +++ b/src/dnd.py @@ -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}" \ No newline at end of file diff --git a/src/engine.py b/src/engine.py index a21e1f6..b85ca49 100644 --- a/src/engine.py +++ b/src/engine.py @@ -1,102 +1,275 @@ -"""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) + + # 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} - def is_alive(self): + @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) - 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] + 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}" - # Rarity based on luck and level - roll = random.randint(1, 100) + self.character.lck - if roll > 95: + level = max(1, self.character.level + random.randint(-1, 1)) + return DnDMonster(name, level, self.character.level) + + 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 + 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.""" + 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}[/]") + + # 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 - while monster.is_alive() and self.character.is_alive(): + 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 - # 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 + # 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 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} - if not self.character.is_alive(): - 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} @@ -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 \ No newline at end of file diff --git a/src/llm.py b/src/llm.py deleted file mode 100644 index 3becadd..0000000 --- a/src/llm.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/main.py b/src/main.py index 7086dbd..752aa30 100644 --- a/src/main.py +++ b/src/main.py @@ -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) + # === Event Log === + event_text = Text() + for entry in engine.event_log: + t = Text.from_markup(entry) + event_text.append(t) + event_text.append("\n") - # 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): - t = Text.from_markup(entry) - log_text.append(t) - log_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") + 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)