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.utils.common import Dice
from app.game.models.entities import Entity 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() 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}
race = templates.race(race_id)
profession = templates.profession(class_id) 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 = races.get(race_id)
profession = professions.get(profession_id)
e = Entity( e = Entity(
uuid = str(uuid.uuid4()), uuid = str(uuid.uuid4()),
name = name, name = name,
origin_story = origin_story, origin_story = origin_story,
fame = fame, fame = fame,
level=1, race =race,
race =race.get("name"), profession = profession
profession = profession.get("name")
) )
# assign hit dice # apply race ability scores
e.hit_die = profession.get("hit_die") for stat, delta in vars(race.ability_scores).items():
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")
# assign hp/mp # Get current score (default to 10 if missing)
e.hp = hp_and_mp_gain(1,e.per_level.hp_die,e.per_level.hp_rule) current = getattr(e.ability_scores, stat, 10)
e.mp = hp_and_mp_gain(1,e.per_level.mp_die,e.per_level.mp_rule)
for stat, delta in race.get("ability_mods", {}).items(): # Apply modifier
e.ability_scores[stat] = e.ability_scores.get(stat, 10) + int(delta) new_value = current + int(delta)
# Apply class grants (spells/skills/equipment + MP base) # Update the stat
grants = profession.get("grants", {}) setattr(e.ability_scores, stat, new_value)
# add spells to char set_level(e,level,progression)
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 [])
return e 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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,25 +2,37 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List, Any, Tuple 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 @dataclass
class PerLevel: class AbilityScores:
hp_die: str = "1d10" STR: int = 10
hp_rule: str = "avg" DEX: int = 10
mp_die: str = "1d6" INT: int = 10
mp_rule: str = "avg" 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 @dataclass
class Entity: class Entity:
uuid: str = "" uuid: str = ""
name: str = "" name: str = ""
race: str = ""
profession: str = ""
origin_story: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 level: int = 0
xp: int = 0 xp: int = 0
@@ -29,10 +41,16 @@ class Entity:
fame: int = 0 fame: int = 0
alignment: int = 0 alignment: int = 0
per_level: PerLevel = field(default_factory=PerLevel) physical_attack: int = 1
ability_scores: Dict[str, int] = field(default_factory=dict) physical_defense: int = 1
magic_attack: int = 1
magic_defense: int = 1
status: Status = field(default_factory=Status)
ability_scores: AbilityScores = field(default_factory=AbilityScores)
weapons: List[Dict[str, int]] = field(default_factory=list) weapons: List[Dict[str, int]] = field(default_factory=list)
armor: 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) skills: List[Dict[str, int]] = field(default_factory=list)

View File

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

56
app/game/models/races.py Normal file
View File

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

View File

@@ -1,72 +1,105 @@
# utils/leveling.py
from app.utils.logging import get_logger from typing import Dict, Any, Callable, Optional, List, Tuple
from app.game.generators.level_progression import LevelProgression
from app.game.utils.common import Dice
from app.game.models.entities import Entity from app.game.models.entities import Entity
from app.game.generators.abilities_factory import newly_unlocked_abilities
logger = get_logger(__file__) def set_level(entity:Entity, target_level: int, prog: LevelProgression) -> None:
dice = Dice()
def calculate_xp_and_level(current_xp: int, xp_gain: int, base_xp: int = 100, growth_rate: float = 1.2):
""" """
Calculates new XP and level after gaining experience. 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).
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 levels XP requirement.
Returns:
dict: {
'new_xp': int,
'new_level': int,
'xp_to_next_level': int,
'remaining_xp_to_next': int
}
""" """
# Calculate current level based on XP # ensures we never try to go above the max level in the game
level = 1 target_level = max(1, min(target_level, prog.max_level))
xp_needed = base_xp current = entity.level
total_xp_for_next = xp_needed
# Determine current level from current_xp # if not changing levels, just set the xp and move along
while current_xp >= total_xp_for_next: if current == target_level:
level += 1 entity.xp = prog.xp_for_level(target_level)
xp_needed = int(base_xp * (growth_rate ** (level - 1))) _recalc(entity)
total_xp_for_next += xp_needed
# Add new XP # Set final level + floor XP
new_xp = current_xp + xp_gain entity.level = target_level
entity.xp = prog.xp_for_level(target_level)
# Check if level up(s) occurred # set next level xp as xp needed for next level
new_level = level entity.xp_to_next_level = prog.xp_for_level(target_level + 1)
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
# XP required for next level spells_list = newly_unlocked_abilities(class_name=entity.profession.name,
xp_to_next_level = xp_needed path="Hellknight",
levels_gained = new_level - level level=target_level,
per_tier=1,
primary=entity.profession.primary_stat)
_add_abilities(entity,spells_list)
return { _recalc(entity)
"new_xp": new_xp,
"new_level": new_level, def grant_xp(entity:Entity, amount: int, prog: LevelProgression) -> Tuple[int, int]:
"xp_to_next_level": xp_to_next_level, """
"levels_gained": levels_gained 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,36 @@
from app.utils.logging import get_logger from app.utils.logging import get_logger
from dataclasses import asdict from dataclasses import asdict
import json
logger = get_logger(__file__) logger = get_logger(__file__)
from app.game.generators.entity_factory import build_char 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( player = build_char(
name="Philbert", name="Philbert",
origin_story="I came from a place", origin_story="I came from a place",
race_id="terran", 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! # MOVE HIT DICE TO WEAPONS!
# ADD DEFENSE STAT # ADD DEFENSE STAT
# ADD ATTACK STAT - this will help with combat! # ADD ATTACK STAT - this will help with combat!
new_xp_stats = calculate_xp_and_level(current_xp=player.xp,xp_gain=200) # import json
# player_dict = asdict(player)
player.xp = new_xp_stats.get("new_xp") # print(json.dumps(player_dict,indent=True))
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))
# serialize / deserialize # serialize / deserialize