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}
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(
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)

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 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"
hp: int = 1
mp: int = 0
energy_credits: int = 0
race: Race = field(default_factory=Race)
profession: Profession = field(default_factory=Profession)
level: int = 0
xp: int = 0
@@ -29,10 +41,16 @@ class Entity:
fame: int = 0
alignment: int = 0
per_level: PerLevel = field(default_factory=PerLevel)
ability_scores: Dict[str, int] = field(default_factory=dict)
physical_attack: int = 1
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)
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)

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

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