complete regen of hero classes, spells, races, etc
This commit is contained in:
86
app/game/generators/abilities_factory.py
Normal file
86
app/game/generators/abilities_factory.py
Normal 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]
|
||||
@@ -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
|
||||
73
app/game/generators/level_progression.py
Normal file
73
app/game/generators/level_progression.py
Normal 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)
|
||||
Reference in New Issue
Block a user