complete regen of hero classes, spells, races, etc

This commit is contained in:
2025-11-02 18:16:00 -06:00
parent 7bf81109b3
commit fd572076e0
34 changed files with 882 additions and 489 deletions

View File

@@ -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]

View File

@@ -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

View File

@@ -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)