From fd572076e08b2949233d1d1ebca61c8870a5e875 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 2 Nov 2025 18:16:00 -0600 Subject: [PATCH] complete regen of hero classes, spells, races, etc --- app/game/generators/abilities_factory.py | 86 ++++++++ app/game/generators/entity_factory.py | 58 +++--- app/game/generators/level_progression.py | 73 +++++++ app/game/loaders/profession_loader.py | 44 +++++ app/game/loaders/races_loader.py | 50 +++++ app/game/models/abilities.py | 17 ++ app/game/models/enemies.py | 27 +++ app/game/models/entities.py | 48 +++-- app/game/models/professions.py | 93 +++++++++ app/game/models/races.py | 56 ++++++ app/game/systems/leveling.py | 157 +++++++++------ app/game/templates/ability_paths.yaml | 41 ++++ app/game/templates/classes.yaml | 88 --------- app/game/templates/professions/arcanist.yaml | 11 ++ app/game/templates/professions/assassin.yaml | 11 ++ app/game/templates/professions/bloodborn.yaml | 11 ++ app/game/templates/professions/cleric.yaml | 11 ++ app/game/templates/professions/general.yaml | 25 +++ app/game/templates/professions/guardian.yaml | 11 ++ app/game/templates/professions/hexist.yaml | 11 ++ app/game/templates/professions/ranger.yaml | 11 ++ app/game/templates/professions/warlock.yaml | 11 ++ app/game/templates/races.yaml | 48 ----- app/game/templates/races/avaline.yaml | 14 ++ app/game/templates/races/beastfolk.yaml | 14 ++ app/game/templates/races/draconian.yaml | 14 ++ app/game/templates/races/dwarf.yaml | 14 ++ app/game/templates/races/elf.yaml | 14 ++ app/game/templates/races/hellion.yaml | 14 ++ app/game/templates/races/terran.yaml | 14 ++ app/game/templates/races/vorgath.yaml | 14 ++ app/game/templates/spells.yaml | 184 ------------------ app/game/utils/loaders.py | 50 ----- new_hero.py | 26 +-- 34 files changed, 882 insertions(+), 489 deletions(-) create mode 100644 app/game/generators/abilities_factory.py create mode 100644 app/game/generators/level_progression.py create mode 100644 app/game/loaders/profession_loader.py create mode 100644 app/game/loaders/races_loader.py create mode 100644 app/game/models/abilities.py create mode 100644 app/game/models/enemies.py create mode 100644 app/game/models/professions.py create mode 100644 app/game/models/races.py create mode 100644 app/game/templates/ability_paths.yaml delete mode 100644 app/game/templates/classes.yaml create mode 100644 app/game/templates/professions/arcanist.yaml create mode 100644 app/game/templates/professions/assassin.yaml create mode 100644 app/game/templates/professions/bloodborn.yaml create mode 100644 app/game/templates/professions/cleric.yaml create mode 100644 app/game/templates/professions/general.yaml create mode 100644 app/game/templates/professions/guardian.yaml create mode 100644 app/game/templates/professions/hexist.yaml create mode 100644 app/game/templates/professions/ranger.yaml create mode 100644 app/game/templates/professions/warlock.yaml delete mode 100644 app/game/templates/races.yaml create mode 100644 app/game/templates/races/avaline.yaml create mode 100644 app/game/templates/races/beastfolk.yaml create mode 100644 app/game/templates/races/draconian.yaml create mode 100644 app/game/templates/races/dwarf.yaml create mode 100644 app/game/templates/races/elf.yaml create mode 100644 app/game/templates/races/hellion.yaml create mode 100644 app/game/templates/races/terran.yaml create mode 100644 app/game/templates/races/vorgath.yaml delete mode 100644 app/game/templates/spells.yaml delete mode 100644 app/game/utils/loaders.py diff --git a/app/game/generators/abilities_factory.py b/app/game/generators/abilities_factory.py new file mode 100644 index 0000000..ac3bd18 --- /dev/null +++ b/app/game/generators/abilities_factory.py @@ -0,0 +1,86 @@ +import random, hashlib, re +from typing import List, Dict, Any +from pathlib import Path + +import yaml + +from app.game.models.abilities import Ability + + +# ----------------- Helpers ----------------- +def _load_path_themes(filepath: str | Path) -> Dict[str, Dict[str, Any]]: + """Load PATH_THEMES-style data from a YAML file.""" + filepath = Path(filepath) + if not filepath.exists(): + raise FileNotFoundError(f"Theme file not found: {filepath}") + with open(filepath, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + if not isinstance(data, dict): + raise ValueError("Invalid format: root must be a mapping of paths.") + return data + +def _stable_seed(*parts, version: int) -> int: + s = ":".join(str(p) for p in parts) + f":v{version}" + return int(hashlib.sha256(s.encode()).hexdigest(), 16) % (2**32) + +def _slugify(s: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") + +def _pick(rng: random.Random, xs: List[str]) -> str: + return rng.choice(xs) + +def _name(rng: random.Random, theme: Dict) -> str: + # Two simple patterns to keep variety but consistency + if rng.random() < 0.5: + return f"{_pick(rng, theme['adjectives'])} {_pick(rng, theme['nouns'])}" + return f"{_pick(rng, theme['nouns'])} of {_pick(rng, theme['of_things'])}" + +def _damage_power(tier: int, rng: random.Random) -> float: + # Linear-with-jitter curve—easy to reason about and scale + base = 50 + 7 * tier + jitter = 1.0 + rng.uniform(-0.08, 0.08) + return round(base * jitter, 2) + +def _mp_cost(tier: int, rng: random.Random) -> int: + # Grows gently with tier + return max(1, int(round(12 + 1.8 * tier + rng.uniform(-0.5, 0.5)))) + +# Add more paths as you like +themes_filename = Path() / "app" / "game" / "templates" / "ability_paths.yaml" +PATH_THEMES = _load_path_themes(themes_filename) + +# ----------------- Generator ----------------- +def generate_abilities_direct_damage(class_name: str,path: str,level: int, primary_stat:str , per_tier: int = 2,content_version: int = 1) -> List[Ability]: + theme = PATH_THEMES[path] + rng = random.Random(_stable_seed(class_name, path, level, per_tier, version=content_version)) + spells: List[Ability] = [] + for tier in range(1, level + 1): + for _ in range(per_tier): + name = _name(rng, theme) + elem = _pick(rng, theme["elements"]) + dmg = _damage_power(tier, rng) + cost = _mp_cost(tier, rng) + coeff = round(1.05 + 0.02 * tier, 2) # mild growth with tier + + rules = f"Deal {elem} damage (power {dmg}, {primary_stat}+{int(coeff*100)}%)." + spell_id = _slugify(f"{class_name}-{path}-t{tier}-{name}") + + spells.append(Ability( + id=spell_id, + name=name, + class_name=class_name, + path=path, + tier=tier, + element=elem, + cost_mp=cost, + damage_power=dmg, + scaling_stat=primary_stat, + scaling_coeff=coeff, + rules_text=rules + )) + return spells + +# Convenience: “new at this level only” +def newly_unlocked_abilities(class_name: str, path: str, level: int, primary:str, per_tier: int = 2, content_version: int = 1, ) -> List[Ability]: + return [s for s in generate_abilities_direct_damage(class_name=class_name, path=path,level=level,primary_stat=primary,per_tier=per_tier,content_version=content_version) + if s.tier == level] \ No newline at end of file diff --git a/app/game/generators/entity_factory.py b/app/game/generators/entity_factory.py index 09fdf34..ac9341f 100644 --- a/app/game/generators/entity_factory.py +++ b/app/game/generators/entity_factory.py @@ -3,54 +3,50 @@ from typing import Dict, List, Any, Tuple from app.game.utils.common import Dice from app.game.models.entities import Entity -from app.game.utils.loaders import TemplateStore -from app.game.systems.leveling import hp_and_mp_gain + +from app.game.loaders.races_loader import RaceRepository +from app.game.loaders.profession_loader import ProfessionRepository +from app.game.generators.level_progression import DEFAULT_LEVEL_PROGRESSION +from app.game.systems.leveling import set_level dice = Dice() -def build_char(name:str, origin_story:str, race_id:str, class_id:str, fame:int=0) -> Entity: +# tuning knobs +level_growth = 1.25 - templates = TemplateStore() - # base_ability_scores = {"STR":10,"DEX":10,"CON":10,"INT":10,"WIS":10,"CHA":10, "LUC":10} + + +def build_char(name:str, origin_story:str, race_id:str, profession_id:str, fame:int=0, level:int=1) -> Entity: + + races = RaceRepository() + professions = ProfessionRepository() + progression = DEFAULT_LEVEL_PROGRESSION - race = templates.race(race_id) - profession = templates.profession(class_id) + race = races.get(race_id) + profession = professions.get(profession_id) e = Entity( uuid = str(uuid.uuid4()), name = name, origin_story = origin_story, fame = fame, - level=1, - race =race.get("name"), - profession = profession.get("name") + race =race, + profession = profession ) - # assign hit dice - e.hit_die = profession.get("hit_die") - e.per_level.hp_die = profession.get("per_level",{}).get("hp_rule",{}).get("die","1d10") - e.per_level.mp_die = profession.get("per_level",{}).get("mp_rule",{}).get("die","1d10") - e.per_level.hp_rule = profession.get("per_level",{}).get("hp_rule",{}).get("rule","avg") - e.per_level.mp_rule = profession.get("per_level",{}).get("mp_rule",{}).get("rule","avg") + # apply race ability scores + for stat, delta in vars(race.ability_scores).items(): - # assign hp/mp - e.hp = hp_and_mp_gain(1,e.per_level.hp_die,e.per_level.hp_rule) - e.mp = hp_and_mp_gain(1,e.per_level.mp_die,e.per_level.mp_rule) + # Get current score (default to 10 if missing) + current = getattr(e.ability_scores, stat, 10) - for stat, delta in race.get("ability_mods", {}).items(): - e.ability_scores[stat] = e.ability_scores.get(stat, 10) + int(delta) + # Apply modifier + new_value = current + int(delta) - # Apply class grants (spells/skills/equipment + MP base) - grants = profession.get("grants", {}) + # Update the stat + setattr(e.ability_scores, stat, new_value) - # add spells to char - grant_spells = grants.get("spells", []) or [] - for spell in grant_spells: - spell_dict = templates.spell(spell) - e.spells.append(spell_dict) - - # TODO - # e.skills.extend(grants.get("skills", []) or []) + set_level(e,level,progression) return e \ No newline at end of file diff --git a/app/game/generators/level_progression.py b/app/game/generators/level_progression.py new file mode 100644 index 0000000..99888bd --- /dev/null +++ b/app/game/generators/level_progression.py @@ -0,0 +1,73 @@ +from __future__ import annotations +from dataclasses import dataclass +from bisect import bisect_right +from typing import List, Dict, Any, Optional + + +@dataclass(frozen=True) +class LevelThreshold: + level: int + xp: int # cumulative XP required to *reach* this level + rewards: Dict[str, Any] # optional; can be empty + +class LevelProgression: + """ + Provides XP<->Level mapping. Can be built from a parametric curve or YAML-like dict. + """ + def __init__(self, thresholds: List[LevelThreshold]): + # Normalize, unique-by-level, sorted + uniq = {} + for t in thresholds: + uniq[t.level] = LevelThreshold(level=t.level, xp=int(t.xp), rewards=(t.rewards or {})) + levels = sorted(uniq.values(), key=lambda x: x.level) + if not levels or levels[0].level != 1 or levels[0].xp != 0: + raise ValueError("Progression must start at level 1 with xp=0") + object.__setattr__(self, "_levels", levels) + object.__setattr__(self, "_xp_list", [t.xp for t in levels]) + object.__setattr__(self, "_level_list", [t.level for t in levels]) + + @classmethod + def from_curve(cls, max_level: int = 100, base_xp: int = 100, growth: float = 1.45) -> "LevelProgression": + """ + Exponential-ish cumulative thresholds: + XP(level L) = round(base_xp * ((growth^(L-1) - 1) / (growth - 1))) + Ensures level 1 => 0 XP. + """ + thresholds: List[LevelThreshold] = [] + for L in range(1, max_level + 1): + if L == 1: + xp = 0 + else: + # cumulative sum of geometric series + xp = round(base_xp * ((growth ** (L - 1) - 1) / (growth - 1))) + thresholds.append(LevelThreshold(level=L, xp=xp, rewards={})) + return cls(thresholds) + + # --------- Queries ---------- + @property + def max_level(self) -> int: + return self._level_list[-1] + + def xp_for_level(self, level: int) -> int: + if level < 1: return 0 + if level >= self.max_level: return self._xp_list[-1] + # Levels are dense and 1-indexed + idx = level - 1 + return self._xp_list[idx] + + def level_for_xp(self, xp: int) -> int: + """ + Find the highest level where threshold.xp <= xp. Levels are dense. + """ + if xp < 0: return 1 + idx = bisect_right(self._xp_list, xp) - 1 + if idx < 0: return 1 + return self._level_list[idx] + + def rewards_for_level(self, level: int) -> Dict[str, Any]: + if level < 2: return {} + if level > self.max_level: level = self.max_level + return self._levels[level - 1].rewards or {} + +LEVEL_CURVE = 1.25 +DEFAULT_LEVEL_PROGRESSION = LevelProgression.from_curve(growth=LEVEL_CURVE) \ No newline at end of file diff --git a/app/game/loaders/profession_loader.py b/app/game/loaders/profession_loader.py new file mode 100644 index 0000000..e10c4e4 --- /dev/null +++ b/app/game/loaders/profession_loader.py @@ -0,0 +1,44 @@ +from pathlib import Path +from typing import Dict, Iterable, Optional +import yaml + +from app.game.models.professions import Profession + +class ProfessionRepository: + def __init__(self): + + self.root = Path() / "app" / "game" / "templates" / "professions" + self._by_id: Dict[str, Profession] = {} + self.load() + + @staticmethod + def _iter_yaml_files(root: Path) -> Iterable[Path]: + # Recursively find *.yaml under the root + yield from root.rglob("*.yaml") + + def load(self) -> None: + for path in self._iter_yaml_files(self.root): + with path.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + profession = Profession.from_yaml(data) + + if profession.id in self._by_id: + raise ValueError(f"Duplicate profession id '{profession.id}' in {path}") + + self._by_id.update({profession.id:profession}) + + + # ----- Queries ----- + + def get(self, profession_id: str) -> Optional[Profession]: + return self._by_id.get(profession_id) + + def list_all(self) -> list[Profession]: + return list(self._by_id.values()) + + def list_by_tag(self, tag: str) -> list[Profession]: + return [r for r in self._by_id.values() if tag in r.tags] + + def list_by_any_tags(self, tags: set[str]) -> list[Profession]: + return [r for r in self._by_id.values() if r.tags & tags] \ No newline at end of file diff --git a/app/game/loaders/races_loader.py b/app/game/loaders/races_loader.py new file mode 100644 index 0000000..f539c1f --- /dev/null +++ b/app/game/loaders/races_loader.py @@ -0,0 +1,50 @@ +from pathlib import Path +from typing import Dict, Iterable, Optional +import yaml + +from app.game.models.races import Race + +class RaceRepository: + def __init__(self): + + self.root = Path() / "app" / "game" / "templates" / "races" + self._by_id: Dict[str, Race] = {} + self.load() + + @staticmethod + def _iter_yaml_files(root: Path) -> Iterable[Path]: + # Recursively find *.yaml under the root + yield from root.rglob("*.yaml") + + def load(self) -> None: + for path in self._iter_yaml_files(self.root): + with path.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + race = Race.from_yaml(data) + + if race.id in self._by_id: + raise ValueError(f"Duplicate Race id '{race.id}' in {path}") + + self._by_id.update({race.id:race}) + + + # ----- Queries ----- + + def get(self, race_id: str) -> Optional[Race]: + return self._by_id.get(race_id) + + def list_all(self) -> list[Race]: + return list(self._by_id.values()) + + def list_playable(self) -> list[Race]: + return [r for r in self._by_id.values() if r.is_playable] + + def list_non_playable(self) -> list[Race]: + return [r for r in self._by_id.values() if not r.is_playable] + + def list_by_tag(self, tag: str) -> list[Race]: + return [r for r in self._by_id.values() if tag in r.tags] + + def list_by_any_tags(self, tags: set[str]) -> list[Race]: + return [r for r in self._by_id.values() if r.tags & tags] \ No newline at end of file diff --git a/app/game/models/abilities.py b/app/game/models/abilities.py new file mode 100644 index 0000000..bb74a01 --- /dev/null +++ b/app/game/models/abilities.py @@ -0,0 +1,17 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Dict + +@dataclass(frozen=True) +class Ability: + id: str + name: str + class_name: str + path: str + tier: int # 1..level; use this as your learn/unlock gate + element: str + cost_mp: int + damage_power: float # abstract power; plug into your combat math + scaling_stat: str # e.g., "CHA" + scaling_coeff: float# e.g., 1.22 means +122% of stat modifier + rules_text: str # readable summary (optional) \ No newline at end of file diff --git a/app/game/models/enemies.py b/app/game/models/enemies.py new file mode 100644 index 0000000..2cbabd0 --- /dev/null +++ b/app/game/models/enemies.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Dict, Literal + +Difficulty = Literal["easy","standard","tough","elite","boss"] + +@dataclass(frozen=True) +class EnemyProfile: + # Level selection relative to player + level_bias: int = 0 # e.g., +2 spawns slightly above player level + level_variance: int = 1 # random wiggle (0..variance) + min_level: int = 1 + max_level: int = 999 + + # Global power multipliers (applied after base+per-level growth) + hp_mult: float = 1.0 + dmg_mult: float = 1.0 + armor_mult: float = 1.0 + speed_mult: float = 1.0 + + # Optional stat emphasis for auto-building enemy ability scores + stat_weights: Dict[str, float] = field(default_factory=dict) # e.g. {"STR":1.2,"DEX":1.1} + + # Rewards + xp_base: int = 10 + xp_per_level: int = 5 + loot_tier: str = "common" diff --git a/app/game/models/entities.py b/app/game/models/entities.py index db6033e..f4c58ac 100644 --- a/app/game/models/entities.py +++ b/app/game/models/entities.py @@ -2,37 +2,55 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Dict, List, Any, Tuple +from app.game.models.races import Race +from app.game.models.professions import Profession +from app.game.models.abilities import Ability + @dataclass -class PerLevel: - hp_die: str = "1d10" - hp_rule: str = "avg" - mp_die: str = "1d6" - mp_rule: str = "avg" +class AbilityScores: + STR: int = 10 + DEX: int = 10 + INT: int = 10 + WIS: int = 10 + CON: int = 10 + LUK: int = 10 + CHA: int = 10 + +@dataclass +class Status: + max_hp: int = 0 + max_mp: int = 0 + current_hp: int=0 + current_mp: int=0 + max_energy_credits: int = 0 + current_energy_credits: int = 0 + is_dead = False @dataclass class Entity: uuid: str = "" name: str = "" - race: str = "" - profession: str = "" origin_story:str = "" - hit_die: str = "1d6" + race: Race = field(default_factory=Race) + profession: Profession = field(default_factory=Profession) - hp: int = 1 - mp: int = 0 - energy_credits: int = 0 - level: int = 0 xp: int = 0 xp_to_next_level:int = 100 fame: int = 0 alignment: int = 0 + + physical_attack: int = 1 + physical_defense: int = 1 + + magic_attack: int = 1 + magic_defense: int = 1 - per_level: PerLevel = field(default_factory=PerLevel) - ability_scores: Dict[str, int] = field(default_factory=dict) + status: Status = field(default_factory=Status) + ability_scores: AbilityScores = field(default_factory=AbilityScores) weapons: List[Dict[str, int]] = field(default_factory=list) armor: List[Dict[str, int]] = field(default_factory=list) - spells: List[Dict[str, int]] = field(default_factory=list) + abilities: List[Ability] = field(default_factory=list) skills: List[Dict[str, int]] = field(default_factory=list) diff --git a/app/game/models/professions.py b/app/game/models/professions.py new file mode 100644 index 0000000..b45c606 --- /dev/null +++ b/app/game/models/professions.py @@ -0,0 +1,93 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Dict, Optional + +from app.game.models.enemies import EnemyProfile + +@dataclass(frozen=True) +class Profession: + id: str + name: str + description: str + primary_stat: str + base_hp: int + base_mp: int + physical_attack_per_level: float + physical_defense_per_level: float + magic_attack_per_level: float + magic_defense_per_level: float + + tags: list[str] = field(default_factory=list) # e.g., {"playable"}, {"leader","elite"} + enemy: Optional[EnemyProfile] = None # ⬅ optional enemy-only tuning + + @property + def is_playable(self) -> bool: + return "playable" in self.tags + + @staticmethod + def from_yaml(data: Dict[str, object]) -> "Profession": + # ---- validation of required profession fields ---- + required = [ + "id", "name", "description", "primary_stat", + "base_hp", "base_mp", + "physical_attack_per_level", "physical_defense_per_level", + "magic_attack_per_level", "magic_defense_per_level" + ] + missing = [k for k in required if k not in data] + if missing: + raise ValueError(f"Profession missing required fields: {missing}") + + # ---- cast helpers (robust to str/int/float in YAML) ---- + def as_int(x, field_name): + try: return int(x) + except Exception: raise ValueError(f"{field_name} must be int, got {x!r}") + def as_float(x, field_name): + try: return float(x) + except Exception: raise ValueError(f"{field_name} must be float, got {x!r}") + + # ---- tags (optional) ---- + tags = list(data.get("tags", []) or []) + + # ---- enemy block (optional) ---- + enemy_block = data.get("enemy") + enemy: Optional[EnemyProfile] = None + if enemy_block: + eb = enemy_block or {} + # typed extraction with defaults from EnemyProfile + enemy = EnemyProfile( + level_bias=as_int(eb.get("level_bias", 0), "enemy.level_bias"), + level_variance=as_int(eb.get("level_variance", 1), "enemy.level_variance"), + min_level=as_int(eb.get("min_level", 1), "enemy.min_level"), + max_level=as_int(eb.get("max_level", 999), "enemy.max_level"), + hp_mult=as_float(eb.get("hp_mult", 1.0), "enemy.hp_mult"), + dmg_mult=as_float(eb.get("dmg_mult", 1.0), "enemy.dmg_mult"), + armor_mult=as_float(eb.get("armor_mult", 1.0), "enemy.armor_mult"), + speed_mult=as_float(eb.get("speed_mult", 1.0), "enemy.speed_mult"), + stat_weights={str(k): float(v) for k, v in (eb.get("stat_weights", {}) or {}).items()}, + xp_base=as_int(eb.get("xp_base", 10), "enemy.xp_base"), + xp_per_level=as_int(eb.get("xp_per_level", 5), "enemy.xp_per_level"), + loot_tier=str(eb.get("loot_tier", "common")), + ) + # sanity checks + if enemy.min_level < 1: + raise ValueError("enemy.min_level must be >= 1") + if enemy.max_level < enemy.min_level: + raise ValueError("enemy.max_level must be >= enemy.min_level") + if any(v < 0 for v in (enemy.hp_mult, enemy.dmg_mult, enemy.armor_mult, enemy.speed_mult)): + raise ValueError("enemy multipliers must be >= 0") + + # ---- construct Profession ---- + return Profession( + id=str(data["id"]), + name=str(data["name"]), + description=str(data["description"]), + primary_stat=str(data["primary_stat"]), + base_hp=as_int(data["base_hp"], "base_hp"), + base_mp=as_int(data["base_mp"], "base_mp"), + physical_attack_per_level=as_float(data["physical_attack_per_level"], "physical_attack_per_level"), + physical_defense_per_level=as_float(data["physical_defense_per_level"], "physical_defense_per_level"), + magic_attack_per_level=as_float(data["magic_attack_per_level"], "magic_attack_per_level"), + magic_defense_per_level=as_float(data["magic_defense_per_level"], "magic_defense_per_level"), + tags=tags, + enemy=enemy, + ) diff --git a/app/game/models/races.py b/app/game/models/races.py new file mode 100644 index 0000000..fc78ee3 --- /dev/null +++ b/app/game/models/races.py @@ -0,0 +1,56 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Dict + +@dataclass +class AbilityScores: + STR: int = 0 + DEX: int = 0 + INT: int = 0 + WIS: int = 0 + CON: int = 0 + LUK: int = 0 + CHA: int = 0 + +@dataclass(frozen=True) +class Race: + id: str + name: str + description: str + alignment: int = 0 + ability_scores: AbilityScores = field(default_factory=AbilityScores) + tags: list[str] = field(default_factory=list) # e.g., {"playable"}, {"hostile","goblinoid"} + + @property + def is_playable(self) -> bool: + return "playable" in self.tags + + @staticmethod + def from_yaml(data: Dict[str, object]) -> "Race": + # basic validation with helpful errors + required = ["id", "name", "description", "alignment", "ability_mods"] + missing = [k for k in required if k not in data] + if missing: + raise ValueError(f"Spell missing required fields: {missing}") + + mods = data.get("ability_mods", {}) or {} + + abs = AbilityScores( + STR=int(mods.get("STR", 0)), + DEX=int(mods.get("DEX", 0)), + INT=int(mods.get("INT", 0)), + WIS=int(mods.get("WIS", 0)), + CON=int(mods.get("CON", 0)), + LUK=int(mods.get("LUK", 0)), + CHA=int(mods.get("CHA", 0)), + ) + + tags = list(data.get("tags", [])) + + return Race( + id=str(data["id"]), + name=str(data["name"]), + description=str(data["description"]), + ability_scores=abs, + tags=tags + ) \ No newline at end of file diff --git a/app/game/systems/leveling.py b/app/game/systems/leveling.py index 3e98854..55d8e69 100644 --- a/app/game/systems/leveling.py +++ b/app/game/systems/leveling.py @@ -1,72 +1,105 @@ - -from app.utils.logging import get_logger - -from app.game.utils.common import Dice +# utils/leveling.py +from typing import Dict, Any, Callable, Optional, List, Tuple +from app.game.generators.level_progression import LevelProgression from app.game.models.entities import Entity +from app.game.generators.abilities_factory import newly_unlocked_abilities -logger = get_logger(__file__) - -dice = Dice() - - -def calculate_xp_and_level(current_xp: int, xp_gain: int, base_xp: int = 100, growth_rate: float = 1.2): +def set_level(entity:Entity, target_level: int, prog: LevelProgression) -> None: """ - Calculates new XP and level after gaining experience. - - Args: - current_xp (int): The player's current total XP. - xp_gain (int): The amount of XP gained. - base_xp (int): XP required for level 1 → 2. - growth_rate (float): Multiplier for each level’s XP requirement. - - Returns: - dict: { - 'new_xp': int, - 'new_level': int, - 'xp_to_next_level': int, - 'remaining_xp_to_next': int - } + Snap an entity to a *specific* level. Sets XP to that level's floor. + Optionally applies all level-up rewards from current_level+1 .. target_level (idempotent if you recompute stats from level). """ - # Calculate current level based on XP - level = 1 - xp_needed = base_xp - total_xp_for_next = xp_needed + # ensures we never try to go above the max level in the game + target_level = max(1, min(target_level, prog.max_level)) + current = entity.level - # Determine current level from current_xp - while current_xp >= total_xp_for_next: - level += 1 - xp_needed = int(base_xp * (growth_rate ** (level - 1))) - total_xp_for_next += xp_needed + # if not changing levels, just set the xp and move along + if current == target_level: + entity.xp = prog.xp_for_level(target_level) + _recalc(entity) - # Add new XP - new_xp = current_xp + xp_gain + # Set final level + floor XP + entity.level = target_level + entity.xp = prog.xp_for_level(target_level) - # Check if level up(s) occurred - new_level = level - while new_xp >= total_xp_for_next: - new_level += 1 - xp_needed = int(base_xp * (growth_rate ** (new_level - 1))) - total_xp_for_next += xp_needed + # set next level xp as xp needed for next level + entity.xp_to_next_level = prog.xp_for_level(target_level + 1) - # XP required for next level - xp_to_next_level = xp_needed - levels_gained = new_level - level + spells_list = newly_unlocked_abilities(class_name=entity.profession.name, + path="Hellknight", + level=target_level, + per_tier=1, + primary=entity.profession.primary_stat) + _add_abilities(entity,spells_list) - return { - "new_xp": new_xp, - "new_level": new_level, - "xp_to_next_level": xp_to_next_level, - "levels_gained": levels_gained - } + _recalc(entity) + +def grant_xp(entity:Entity, amount: int, prog: LevelProgression) -> Tuple[int, int]: + """ + Add XP and auto-level if thresholds crossed. + Returns (old_level, new_level). + """ + old_level = entity.level or 1 + entity.xp = entity.xp + int(amount) + + new_level = prog.level_for_xp(entity.xp) + + if new_level > old_level: + for L in range(old_level + 1, new_level + 1): + spells_list = newly_unlocked_abilities(class_name=entity.profession.name, + path="Hellknight", + level=new_level, + per_tier=1, + primary=entity.profession.primary_stat) + _add_abilities(entity,spells_list) + + if new_level > old_level: + entity.level = new_level + _recalc(entity) + + # --- compute XP to next level --- + if new_level >= prog.max_level: + # Maxed out + entity.xp_to_next_level = 0 + else: + next_floor = prog.xp_for_level(new_level + 1) + entity.xp_to_next_level = max(0, next_floor - entity.xp) + + + return old_level, new_level + +# ---------- reward + recalc helpers ---------- + +def _add_abilities(entity:Entity, abilities_list:list) -> None: + for ability in abilities_list: + entity.abilities.append(ability) + +def _recalc(entity:Entity) -> None: + """ + Recompute derived stats from entity.level + profession. + Replace with your actual formulas. + """ + L = entity.level + prof = entity.profession + + # scale attack/defense by per-level gains + if prof: + base_pa = entity.profession.physical_attack_per_level or 0 + base_pd = entity.profession.physical_defense_per_level or 0 + base_ma = entity.profession.magic_attack_per_level or 0 + base_md = entity.profession.magic_defense_per_level or 0 + + entity.physical_attack = round(base_pa + prof.physical_attack_per_level * (L - 1),2) + entity.physical_defense = round(base_pd + prof.physical_defense_per_level * (L - 1),2) + entity.magic_attack = round(base_ma + prof.magic_attack_per_level * (L - 1),2) + entity.magic_defense = round(base_md + prof.magic_defense_per_level * (L - 1),2) + + # HP/MP growth from profession base + if prof: + entity.status.max_hp = entity.status.max_hp + int(prof.base_hp) + int((L * prof.base_hp) * 0.5) + entity.status.max_mp = entity.status.max_mp + int(prof.base_mp) + int((L * prof.base_mp) * 0.5) + + # set current to max if you keep current_hp/mp + entity.status.current_hp = entity.status.max_hp + entity.status.current_mp = entity.status.max_mp -def hp_and_mp_gain(level: int, hit_die: str, rule: str) -> int: - rule = (rule or "avg").lower() - if level == 1: - # Common RPG convention: level 1 gets max die - return dice.max_of_die(hit_die) - if rule == "max": - return dice.max_of_die(hit_die) - if rule == "avg": - return dice.roll_dice(hit_die) - # default fallback - return dice.roll_dice(hit_die) \ No newline at end of file diff --git a/app/game/templates/ability_paths.yaml b/app/game/templates/ability_paths.yaml new file mode 100644 index 0000000..43ab358 --- /dev/null +++ b/app/game/templates/ability_paths.yaml @@ -0,0 +1,41 @@ +Hellknight: + elements: ["fire", "shadow"] + adjectives: ["Infernal", "Hellfire", "Brimstone", "Abyssal", "Profane", "Ironbound"] + nouns: ["Edict", "Judgment", "Brand", "Verdict", "Smite", "Chain", "Pact", "Decree"] + of_things: ["Dominion", "Torment", "Binding", "Ruin", "Cinders", "Night"] + +Frostbinder: + elements: ["ice", "water"] + adjectives: ["Frozen", "Glacial", "Frigid", "Shivering", "Icy"] + nouns: ["Spike", "Lance", "Shard", "Burst", "Ray"] + of_things: ["Frost", "Winter", "Stillness", "Silence", "Cold"] + +Bloodborn: + elements: ["curse", "shadow"] + adjectives: ["Tainted", "Cursed", "Corrupted", "Polluted", "Toxic", "Deadly"] + nouns: ["Poison", "Infection", "Wound", "Bane", "Plague", "Ravage", "Scourge"] + of_things: ["Infectious", "Contagion", "Disease", "Fungus", "Spore", "Seeds"] + +Assassin: + elements: ["wind", "shadow"] + adjectives: ["Stealthy", "Deadly", "Ruthless", "Silent", "Lethal", "Fearsome"] + nouns: ["Dagger", "Knife", "Poison", "Bolt", "Arrows", "Scimitar", "Twinblade"] + of_things: ["Hunter", "Stalker", "Predator", "Ghost", "Shadow", "Silhouette"] + +Cleric: + elements: ["fire", "light"] + adjectives: ["Holy", "Divine", "Pure", "Blessed", "Sacred", "Radiant"] + nouns: ["Flame", "Lightning", "Bolt", "Sword", "Shield", "Vocation", "Call"] + of_things: ["Healing", "Protection", "Blessing", "Salvation", "Redemption", "Mercy"] + +Hexist: + elements: ["curse", "shadow"] + adjectives: ["Dark", "Malefic", "Sinister", "Maledicta", "Witchlike", "Pestilential"] + nouns: ["Spell", "Hex", "Curse", "Influence", "Taint", "Bane", "Malice"] + of_things: ["Poison", "Deceit", "Demonic", "Abomination", "Evil", "Sinister"] + +Ranger: + elements: ["air", "wind"] + adjectives: ["Wild", "Free", "Wanderer", "Survivor", "Huntress", "Skilled"] + nouns: ["Arrows", "Rifle", "Bow", "Arrowhead", "Shotgun", "Crossbow", "Pistol"] + of_things: ["Wolves", "Forest", "Wilderness", "Hunter", "Tracking", "Ambush"] diff --git a/app/game/templates/classes.yaml b/app/game/templates/classes.yaml deleted file mode 100644 index 2371043..0000000 --- a/app/game/templates/classes.yaml +++ /dev/null @@ -1,88 +0,0 @@ -classes: - guardian: - name: Guardian - description: A protector and defender, skilled in combat and leadership. - hit_die: "2d6" - per_level: - hp_rule: {"die":"1d10", "rule":"max"} - mp_rule: {"die":"1d4", "rule":"avg"} - grants: - spells: [] - skills: [] - - bloodborn: - name: Bloodborn - description: Born from darkness and violence, they wield power with abandon. - hit_die: "2d6" - per_level: - hp_rule: {"die":"1d10", "rule":"max"} - mp_rule: {"die":"1d4", "rule":"avg"} - grants: - spells: [] - skills: [] - - arcanist: - name: Arcanist - description: Arcanists are scholars of the arcane, shaping elemental forces through study and sheer intellect. Each Arcanists past is different, some taught in grand academies, others self-taught hermits obsessed with understanding creation. - hit_die: "1d6" - per_level: - hp_rule: {"die":"1d4", "rule":"avg"} - mp_rule: {"die":"1d10", "rule":"max"} - grants: - spells: ["ember_flicker"] - skills: [] - - hexist: - name: Hexist - description: Twisted sorcerers who wield dark magic to bend reality to their will. - hit_die: "1d10" - per_level: - hp_rule: {"die":"1d4", "rule":"avg"} - mp_rule: {"die":"1d10", "rule":"max"} - grants: - spells: [] - skills: [] - - assassin: - name: Assassin - description: Stealthy killers, trained from a young age to strike without warning. - hit_die: "2d6" - per_level: - hp_rule: {"die":"1d10", "rule":"max"} - mp_rule: {"die":"1d6", "rule":"avg"} - grants: - spells: [] - skills: [] - - ranger: - name: Ranger - description: Skilled hunters who live off the land, tracking prey and evading predators. - hit_die: "2d6" - per_level: - hp_rule: {"die":"1d10", "rule":"max"} - mp_rule: {"die":"1d6", "rule":"avg"} - grants: - spells: [] - skills: [] - - cleric: - name: Cleric - description: Holy warriors, using faith to heal and protect allies, and smite enemies. - hit_die: "3d4" - per_level: - hp_rule: {"die":"1d10", "rule":"avg"} - mp_rule: {"die":"2d6", "rule":"avg"} - grants: - spells: [] - skills: [] - - warlock: - name: Warlock - description: Bargainers with dark forces, wielding forbidden magic for mysterious purposes. - hit_die: "3d4" - per_level: - hp_rule: {"die":"1d10", "rule":"avg"} - mp_rule: {"die":"2d6", "rule":"avg"} - grants: - spells: [] - skills: [] \ No newline at end of file diff --git a/app/game/templates/professions/arcanist.yaml b/app/game/templates/professions/arcanist.yaml new file mode 100644 index 0000000..09ed6eb --- /dev/null +++ b/app/game/templates/professions/arcanist.yaml @@ -0,0 +1,11 @@ +id: arcanist +name: Arcanist +description: Arcanists are scholars of the arcane, shaping elemental forces through study and sheer intellect. Each Arcanists past is different, some taught in grand academies, others self-taught hermits obsessed with understanding creation. +primary_stat: INT +base_hp: 8 +base_mp: 14 +physical_attack_per_level: 1.1 +physical_defense_per_level: 1.1 +magic_attack_per_level: 1.7 +magic_defense_per_level: 1.6 +tags: [playable, mage] \ No newline at end of file diff --git a/app/game/templates/professions/assassin.yaml b/app/game/templates/professions/assassin.yaml new file mode 100644 index 0000000..d830cbe --- /dev/null +++ b/app/game/templates/professions/assassin.yaml @@ -0,0 +1,11 @@ +id: assassin +name: Assassin +description: Stealthy killers, trained from a young age to strike without warning. +primary_stat: DEX +base_hp: 10 +base_mp: 10 +physical_attack_per_level: 1.8 +physical_defense_per_level: 1.4 +magic_attack_per_level: 1.2 +magic_defense_per_level: 1.1 +tags: [playable, flex] \ No newline at end of file diff --git a/app/game/templates/professions/bloodborn.yaml b/app/game/templates/professions/bloodborn.yaml new file mode 100644 index 0000000..6eec77d --- /dev/null +++ b/app/game/templates/professions/bloodborn.yaml @@ -0,0 +1,11 @@ +id: bloodborn +name: Bloodborn +description: Born from darkness and violence, they wield power with abandon. +primary_stat: STR +base_hp: 14 +base_mp: 5 +physical_attack_per_level: 1.8 +physical_defense_per_level: 1.4 +magic_attack_per_level: 1.2 +magic_defense_per_level: 1.1 +tags: [playable, martial] \ No newline at end of file diff --git a/app/game/templates/professions/cleric.yaml b/app/game/templates/professions/cleric.yaml new file mode 100644 index 0000000..eb70059 --- /dev/null +++ b/app/game/templates/professions/cleric.yaml @@ -0,0 +1,11 @@ +id: cleric +name: Cleric +description: Holy warriors, using faith to heal and protect allies, and smite enemies. +primary_stat: WIS +base_hp: 8 +base_mp: 14 +physical_attack_per_level: 1.1 +physical_defense_per_level: 1.1 +magic_attack_per_level: 1.7 +magic_defense_per_level: 1.6 +tags: [playable, mage] \ No newline at end of file diff --git a/app/game/templates/professions/general.yaml b/app/game/templates/professions/general.yaml new file mode 100644 index 0000000..c1d23e5 --- /dev/null +++ b/app/game/templates/professions/general.yaml @@ -0,0 +1,25 @@ +id: general +name: General +description: Tactical leader archetype. +primary_stat: INT +base_hp: 12 +base_mp: 4 +physical_attack_per_level: 1.1 +physical_defense_per_level: 0.6 +magic_attack_per_level: 0.8 +magic_defense_per_level: 0.7 +tags: [leader, elite] + +enemy: + level_bias: 2 + level_variance: 1 + min_level: 3 + max_level: 120 + hp_mult: 1.25 + dmg_mult: 1.15 + armor_mult: 1.1 + speed_mult: 1.0 + stat_weights: { INT: 1.2, WIS: 1.1 } + xp_base: 25 + xp_per_level: 8 + loot_tier: rare diff --git a/app/game/templates/professions/guardian.yaml b/app/game/templates/professions/guardian.yaml new file mode 100644 index 0000000..07493fe --- /dev/null +++ b/app/game/templates/professions/guardian.yaml @@ -0,0 +1,11 @@ +id: guardian +name: Guardian +description: A protector and defender, skilled in combat and leadership. +primary_stat: STR +base_hp: 14 +base_mp: 5 +physical_attack_per_level: 1.8 +physical_defense_per_level: 1.4 +magic_attack_per_level: 1.2 +magic_defense_per_level: 1.1 +tags: [playable, martial] \ No newline at end of file diff --git a/app/game/templates/professions/hexist.yaml b/app/game/templates/professions/hexist.yaml new file mode 100644 index 0000000..889205d --- /dev/null +++ b/app/game/templates/professions/hexist.yaml @@ -0,0 +1,11 @@ +id: hexist +name: Hexist +description: Twisted sorcerers who wield dark magic to bend reality to their will. +primary_stat: INT +base_hp: 8 +base_mp: 14 +physical_attack_per_level: 1.1 +physical_defense_per_level: 1.1 +magic_attack_per_level: 1.7 +magic_defense_per_level: 1.6 +tags: [playable, mage] \ No newline at end of file diff --git a/app/game/templates/professions/ranger.yaml b/app/game/templates/professions/ranger.yaml new file mode 100644 index 0000000..9650e2a --- /dev/null +++ b/app/game/templates/professions/ranger.yaml @@ -0,0 +1,11 @@ +id: ranger +name: Ranger +description: Skilled hunters who live off the land, tracking prey and evading predators. +primary_stat: DEX +base_hp: 10 +base_mp: 10 +physical_attack_per_level: 1.8 +physical_defense_per_level: 1.4 +magic_attack_per_level: 1.2 +magic_defense_per_level: 1.1 +tags: [playable, flex] \ No newline at end of file diff --git a/app/game/templates/professions/warlock.yaml b/app/game/templates/professions/warlock.yaml new file mode 100644 index 0000000..e06ba87 --- /dev/null +++ b/app/game/templates/professions/warlock.yaml @@ -0,0 +1,11 @@ +id: warlock +name: Warlock +description: Bargainers with dark forces, wielding forbidden magic for mysterious purposes. +primary_stat: WIS +base_hp: 8 +base_mp: 14 +physical_attack_per_level: 1.1 +physical_defense_per_level: 1.1 +magic_attack_per_level: 1.7 +magic_defense_per_level: 1.6 +tags: [playable, mage] \ No newline at end of file diff --git a/app/game/templates/races.yaml b/app/game/templates/races.yaml deleted file mode 100644 index b763d1f..0000000 --- a/app/game/templates/races.yaml +++ /dev/null @@ -1,48 +0,0 @@ -races: - avaline: - name: Avaline - description: A good and fair creature that many feel is divine, it's both powerful and intelligent. - ability_mods: { STR: 5, DEX: 0, INT: 3, WIS: 1, LUK: -2, CHA: -1, CON: 0} - alignment: 100 - - beastfolk: - name: Beastfolk - description: Humanoid creatures who are intelligent and fair natured. Half Terran / Half beast. - ability_mods: { STR: 3, DEX: 5, INT: 1, WIS: -2, LUK: -1, CHA: 0, CON: 1} - alignment: 50 - - dwarf: - name: Dwarf - description: Dwarves are stout, bearded, and skilled in mining, smithing, and engineering, often living in underground kingdoms - ability_mods: { STR: 5, DEX: -2, INT: 0, WIS: -1, LUK: 1, CHA: 1, CON: 3} - alignment: 50 - - elf: - name: Elf - description: Elves are mythological beings, often depicted as tall, slender, and agile, with pointed ears, and magical abilities. - ability_mods: { STR: -2, DEX: 5, INT: 3, WIS: 0, LUK: 1, CHA: 1, CON: -1} - alignment: 50 - - draconian: - name: Draconian - description: Draconians are massive, fire-breathing humanoids with scaly skin and noble heritage - ability_mods: { STR: 5, DEX: -1, INT: 3, WIS: 1, LUK: -2, CHA: 0, CON: 1} - alignment: 0 - - terran: - name: Terran - description: Common folk of the land, similar to what some have called Human. - ability_mods: { STR: 1, DEX: 3, INT: 0, WIS: -2, LUK: 5, CHA: 1, CON: -1} - alignment: 0 - - hellion: - name: Hellion - description: A creature from the darkest reaches of the shadow, evil. - ability_mods: { STR: 3, DEX: 1, INT: 1, WIS: 0, LUK: -2, CHA: 5, CON: -1} - alignment: -50 - - vorgath: - name: Vorgath - description: A monstrous creature that's both powerful and intelligent, it's terrifying. - ability_mods: { STR: 5, DEX: 3, INT: 1, WIS: 0, LUK: -1, CHA: -2, CON: 3} - alignment: -100 \ No newline at end of file diff --git a/app/game/templates/races/avaline.yaml b/app/game/templates/races/avaline.yaml new file mode 100644 index 0000000..09de5a5 --- /dev/null +++ b/app/game/templates/races/avaline.yaml @@ -0,0 +1,14 @@ +id: avaline +name: Avaline +description: A good and fair creature that many feel is divine, it's both powerful and intelligent. +alignment: 100 +ability_mods: + STR: 5 + DEX: 0 + INT: 3 + WIS: 1 + LUK: -2 + CHA: -1 + CON: 0 +tags: + - playable \ No newline at end of file diff --git a/app/game/templates/races/beastfolk.yaml b/app/game/templates/races/beastfolk.yaml new file mode 100644 index 0000000..fbef068 --- /dev/null +++ b/app/game/templates/races/beastfolk.yaml @@ -0,0 +1,14 @@ +id: beastfolk +name: Beastfolk +description: Humanoid creatures who are intelligent and fair natured. Half Terran / Half beast. +alignment: 50 +ability_mods: + STR: 3 + DEX: 5 + INT: 1 + WIS: -2 + LUK: -1 + CHA: 0 + CON: 1 +tags: + - playable \ No newline at end of file diff --git a/app/game/templates/races/draconian.yaml b/app/game/templates/races/draconian.yaml new file mode 100644 index 0000000..85333c1 --- /dev/null +++ b/app/game/templates/races/draconian.yaml @@ -0,0 +1,14 @@ +id: draconian +name: Draconian +description: Draconians are massive, fire-breathing humanoids with scaly skin and noble heritage +alignment: 0 +ability_mods: + STR: 5 + DEX: -1 + INT: 3 + WIS: 1 + LUK: -2 + CHA: 0 + CON: 1 +tags: + - playable \ No newline at end of file diff --git a/app/game/templates/races/dwarf.yaml b/app/game/templates/races/dwarf.yaml new file mode 100644 index 0000000..6b00c2e --- /dev/null +++ b/app/game/templates/races/dwarf.yaml @@ -0,0 +1,14 @@ +id: dwarf +name: Dwarf +description: Dwarves are stout, bearded, and skilled in mining, smithing, and engineering, often living in underground kingdoms +alignment: 50 +ability_mods: + STR: 5 + DEX: -2 + INT: 0 + WIS: -1 + LUK: 1 + CHA: 1 + CON: 3 +tags: + - playable \ No newline at end of file diff --git a/app/game/templates/races/elf.yaml b/app/game/templates/races/elf.yaml new file mode 100644 index 0000000..792eb88 --- /dev/null +++ b/app/game/templates/races/elf.yaml @@ -0,0 +1,14 @@ +id: elf +name: Elf +description: Elves are mythological beings, often depicted as tall, slender, and agile, with pointed ears, and magical abilities. +alignment: 50 +ability_mods: + STR: -2 + DEX: 5 + INT: 3 + WIS: 0 + LUK: 1 + CHA: 1 + CON: -1 +tags: + - playable \ No newline at end of file diff --git a/app/game/templates/races/hellion.yaml b/app/game/templates/races/hellion.yaml new file mode 100644 index 0000000..c4cab1a --- /dev/null +++ b/app/game/templates/races/hellion.yaml @@ -0,0 +1,14 @@ +id: hellion +name: Hellion +description: A creature from the darkest reaches of the shadow, evil. +alignment: -50 +ability_mods: + STR: 3 + DEX: 1 + INT: 1 + WIS: 0 + LUK: -2 + CHA: 5 + CON: -1 +tags: + - playable \ No newline at end of file diff --git a/app/game/templates/races/terran.yaml b/app/game/templates/races/terran.yaml new file mode 100644 index 0000000..66556ca --- /dev/null +++ b/app/game/templates/races/terran.yaml @@ -0,0 +1,14 @@ +id: terran +name: Terran +description: Common folk of the land, similar to what some have called Human. +alignment: 0 +ability_mods: + STR: 1 + DEX: 3 + INT: 0 + WIS: -2 + LUK: 5 + CHA: 1 + CON: -1 +tags: + - playable \ No newline at end of file diff --git a/app/game/templates/races/vorgath.yaml b/app/game/templates/races/vorgath.yaml new file mode 100644 index 0000000..3a9bf5d --- /dev/null +++ b/app/game/templates/races/vorgath.yaml @@ -0,0 +1,14 @@ +id: vorgath +name: Vorgath +description: A monstrous creature that's both powerful and intelligent, it's terrifying. +alignment: -100 +ability_mods: + STR: 5 + DEX: 3 + INT: 1 + WIS: 0 + LUK: -1 + CHA: -2 + CON: 3 +tags: + - playable \ No newline at end of file diff --git a/app/game/templates/spells.yaml b/app/game/templates/spells.yaml deleted file mode 100644 index 26cb5ee..0000000 --- a/app/game/templates/spells.yaml +++ /dev/null @@ -1,184 +0,0 @@ -spells: - ember_flicker: - name: Ember Flicker - description: Cast a small burst of flame, dealing minor damage to all enemies within range. - cost_mp: 1 - element: fire - damage: 2 - level: 1 - - spark_streak: - name: Spark Streak - description: Create a trail of sparks that deal minor fire damage to all enemies they pass through. - cost_mp: 4 - element: fire - damage: 6 - level: 5 - - flameburst: - name: Flameburst - description: Unleash a powerful blast of flame, dealing significant damage to all enemies within a small radius. - cost_mp: 10 - element: fire - damage: 20 - level: 8 - - inferno_strike: - name: Inferno Strike - description: Deal massive fire damage to a single target, also applying a burning effect that deals additional damage over time. - cost_mp: 15 - element: fire - damage: 30 - level: 12 - - blaze_wave: - name: Blaze Wave - description: Cast a wave of flame that deals moderate damage to all enemies it passes through, also knocking them back slightly. - cost_mp: 8 - element: fire - damage: 18 - level: 15 - - flame_tongue: - name: Flame Tongue - description: Lick your target with a stream of flame, dealing moderate fire damage and potentially applying a burning effect. - cost_mp: 12 - element: fire - damage: 25 - level: 18 - - incendiary_cloud: - name: Incendiary Cloud - description: Release a cloud of flaming particles that deal minor fire damage to all enemies within a medium radius, also causing them to take additional damage if they are not wearing any heat-resistant gear. - cost_mp: 18 - element: fire - damage: 35 - level: 22 - - scorching_sapling: - name: Scorching Sapling - description: Set a target area on fire, dealing moderate fire damage to all enemies within it and also applying a burning effect. - cost_mp: 12 - element: fire - damage: 25 - level: 23 - - magma_blast: - name: Magma Blast - description: Unleash a powerful blast of molten lava that deals significant damage to all enemies within its area of effect, also potentially causing them to become stunned. - cost_mp: 20 - element: fire - damage: 40 - level: 25 - - flame_ember: - name: Flame Ember - description: Cast a burning ember that deals minor fire damage to all enemies it passes through and applies a chance to set them on fire. - cost_mp: 6 - element: fire - damage: 10 - level: 30 - - blaze_lash: - name: Blaze Lash - description: Deal moderate fire damage to a single target, also potentially applying a burning effect and increasing your attack speed for a short duration. - cost_mp: 15 - element: fire - damage: 28 - level: 32 - - infernal_flames: - name: Infernal Flames - description: Cast a massive wave of flame that deals significant damage to all enemies within its area of effect, also potentially causing them to become stunned. - cost_mp: 25 - element: fire - damage: 50 - level: 35 - - burning_scorch: - name: Burning Scorch - description: Deal massive fire damage to a single target, also applying a burning effect and dealing additional damage over time if they are not wearing any heat-resistant gear. - cost_mp: 25 - element: fire - damage: 45 - level: 38 - - magma_wave: - name: Magma Wave - description: Cast a wave of molten lava that deals moderate fire damage to all enemies it passes through, also potentially causing them to become stunned. - cost_mp: 20 - element: fire - damage: 40 - level: 40 - - flame_burst: - name: Flame Burst - description: Unleash a powerful blast of flame that deals significant damage to all enemies within its area of effect, also potentially causing them to become stunned. - cost_mp: 22 - element: fire - damage: 45 - level: 42 - - inferno_wing: - name: Inferno Wing - description: Deal massive fire damage to a single target, also applying a burning effect and dealing additional damage over time if they are not wearing any heat-resistant gear. - cost_mp: 30 - element: fire - damage: 60 - level: 45 - - flame_frenzy: - name: Flame Frenzy - description: Cast a wave of flame that deals moderate damage to all enemies it passes through, also increasing your attack speed for a short duration and potentially applying a burning effect. - cost_mp: 25 - element: fire - damage: 45 - level: 48 - - infernal_tongue: - name: Infernal Tongue - description: Lick your target with a stream of flame that deals moderate fire damage, potentially applying a burning effect and dealing additional damage over time. - cost_mp: 28 - element: fire - damage: 50 - level: 50 - - magma_rampart: - name: Magma Rampart - description: Cast a massive wall of molten lava that deals significant damage to all enemies within its area of effect, also potentially causing them to become stunned. - cost_mp: 35 - element: fire - damage: 70 - level: 55 - - blazing_scorch: - name: Blazing Scorch - description: Deal massive fire damage to a single target, also applying a burning effect and dealing additional damage over time if they are not wearing any heat-resistant gear. - cost_mp: 35 - element: fire - damage: 70 - level: 58 - - infernal_cloud: - name: Infernal Cloud - description: Release a massive cloud of flaming particles that deal significant damage to all enemies within its area of effect, also potentially causing them to become stunned. - cost_mp: 40 - element: fire - damage: 90 - level: 60 - - magma_crater: - name: Magma Crater - description: Cast a massive wave of molten lava that deals significant damage to all enemies within its area of effect, also potentially causing them to become stunned. - cost_mp: 40 - element: fire - damage: 90 - level: 62 - - infernal_flare: - name: Infernal Flare - description: Unleash a massive blast of flame that deals significant damage to all enemies within its area of effect, also potentially causing them to become stunned. - cost_mp: 45 - element: fire - damage: 100 - level: 65 \ No newline at end of file diff --git a/app/game/utils/loaders.py b/app/game/utils/loaders.py deleted file mode 100644 index baaccc7..0000000 --- a/app/game/utils/loaders.py +++ /dev/null @@ -1,50 +0,0 @@ -from pathlib import Path -from typing import Dict, List, Any, Tuple -from dataclasses import fields, is_dataclass - -import yaml - -class TemplateStore: - def __init__(self): - - # incase we have problems later - # templates_dir = Path(settings.repo_root) / "coc_api" / "app" / "game" / "templates" - templates_dir = Path() / "app" / "game" / "templates" - - races_yml_path = templates_dir / "races.yaml" - classes_yml_path = templates_dir / "classes.yaml" - spells_path = templates_dir / "spells.yaml" - - with open(races_yml_path, "r", encoding="utf-8") as f: - self.races = yaml.safe_load(f)["races"] - with open(classes_yml_path, "r", encoding="utf-8") as f: - self.classes = yaml.safe_load(f)["classes"] - with open(spells_path, "r", encoding="utf-8") as f: - self.spells = yaml.safe_load(f)["spells"] - - def race(self, race_id: str) -> Dict[str, Any]: - if race_id not in self.races: - raise KeyError(f"Unknown race: {race_id}") - return self.races[race_id] - - def profession(self, class_id: str) -> Dict[str, Any]: - if class_id not in self.classes: - raise KeyError(f"Unknown class: {class_id}") - return self.classes[class_id] - - def spell(self, spell_id: str) -> Dict[str, Any]: - if spell_id not in self.spells: - raise KeyError(f"Unknown spell: {spell_id}") - return self.spells[spell_id] - -def from_dict(cls, d): - """Recursively reconstruct dataclasses from dicts.""" - if not is_dataclass(cls): - return d - kwargs = {} - for f in fields(cls): - value = d.get(f.name) - if is_dataclass(f.type): - value = from_dict(f.type, value) - kwargs[f.name] = value - return cls(**kwargs) \ No newline at end of file diff --git a/new_hero.py b/new_hero.py index 9aed124..db81dce 100644 --- a/new_hero.py +++ b/new_hero.py @@ -1,32 +1,36 @@ from app.utils.logging import get_logger from dataclasses import asdict +import json logger = get_logger(__file__) from app.game.generators.entity_factory import build_char -from app.game.systems.leveling import calculate_xp_and_level +from app.game.generators.level_progression import DEFAULT_LEVEL_PROGRESSION +from app.game.systems.leveling import grant_xp player = build_char( name="Philbert", origin_story="I came from a place", race_id="terran", - class_id="arcanist" + # profession_id="arcanist", + profession_id="guardian", + level=3 ) + +old, new = grant_xp(player,(156),DEFAULT_LEVEL_PROGRESSION) +player_dict = asdict(player) +print(json.dumps(player_dict,indent=True)) +exit() + # MOVE HIT DICE TO WEAPONS! # ADD DEFENSE STAT # ADD ATTACK STAT - this will help with combat! -new_xp_stats = calculate_xp_and_level(current_xp=player.xp,xp_gain=200) - -player.xp = new_xp_stats.get("new_xp") -player.level = new_xp_stats.get("new_level") -player.xp_to_next_level = new_xp_stats.get("xp_to_next_level") - -import json -player_dict = asdict(player) -print(json.dumps(player_dict,indent=True)) +# import json +# player_dict = asdict(player) +# print(json.dumps(player_dict,indent=True)) # serialize / deserialize