finished out skills per level, added skill trees to professions templates

This commit is contained in:
2025-11-02 19:08:36 -06:00
parent fd572076e0
commit 31aa0000cc
16 changed files with 361 additions and 26 deletions

View File

@@ -37,7 +37,7 @@ def _name(rng: random.Random, theme: Dict) -> str:
def _damage_power(tier: int, rng: random.Random) -> float: def _damage_power(tier: int, rng: random.Random) -> float:
# Linear-with-jitter curve—easy to reason about and scale # Linear-with-jitter curve—easy to reason about and scale
base = 50 + 7 * tier base = 10 * tier
jitter = 1.0 + rng.uniform(-0.08, 0.08) jitter = 1.0 + rng.uniform(-0.08, 0.08)
return round(base * jitter, 2) return round(base * jitter, 2)

View File

@@ -11,27 +11,23 @@ from app.game.systems.leveling import set_level
dice = Dice() dice = Dice()
# tuning knobs progression = DEFAULT_LEVEL_PROGRESSION
level_growth = 1.25
def build_char(name:str, origin_story:str, race_id:str, profession_id:str,ability_pathway:str,level:int=1) -> Entity:
def build_char(name:str, origin_story:str, race_id:str, profession_id:str, fame:int=0, level:int=1) -> Entity:
races = RaceRepository() races = RaceRepository()
professions = ProfessionRepository() professions = ProfessionRepository()
progression = DEFAULT_LEVEL_PROGRESSION
race = races.get(race_id) race = races.get(race_id)
profession = professions.get(profession_id) profession = professions.get(profession_id)
profession.ability_paths
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,
race =race, race =race,
ability_pathway=ability_pathway,
profession = profession profession = profession
) )

View File

@@ -33,6 +33,7 @@ class Entity:
origin_story:str = "" origin_story:str = ""
race: Race = field(default_factory=Race) race: Race = field(default_factory=Race)
profession: Profession = field(default_factory=Profession) profession: Profession = field(default_factory=Profession)
ability_pathway: str = ""
level: int = 0 level: int = 0
xp: int = 0 xp: int = 0

View File

@@ -16,6 +16,7 @@ class Profession:
physical_defense_per_level: float physical_defense_per_level: float
magic_attack_per_level: float magic_attack_per_level: float
magic_defense_per_level: float magic_defense_per_level: float
ability_paths: list[str] = field(default_factory=list)
tags: list[str] = field(default_factory=list) # e.g., {"playable"}, {"leader","elite"} tags: list[str] = field(default_factory=list) # e.g., {"playable"}, {"leader","elite"}
enemy: Optional[EnemyProfile] = None # ⬅ optional enemy-only tuning enemy: Optional[EnemyProfile] = None # ⬅ optional enemy-only tuning
@@ -88,6 +89,7 @@ class Profession:
physical_defense_per_level=as_float(data["physical_defense_per_level"], "physical_defense_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_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"), magic_defense_per_level=as_float(data["magic_defense_per_level"], "magic_defense_per_level"),
ability_paths=(data.get("ability_paths",[])),
tags=tags, tags=tags,
enemy=enemy, enemy=enemy,
) )

View File

@@ -1,9 +1,14 @@
# utils/leveling.py # utils/leveling.py
import math
import random
from typing import Dict, Any, Callable, Optional, List, Tuple from typing import Dict, Any, Callable, Optional, List, Tuple
from app.game.generators.level_progression import LevelProgression from app.game.generators.level_progression import LevelProgression
from app.game.models.entities import Entity from app.game.models.entities import Entity
from app.game.generators.abilities_factory import newly_unlocked_abilities from app.game.generators.abilities_factory import newly_unlocked_abilities
GLOBAL_CHANCE_PER_LEVEL = 0.3
def set_level(entity:Entity, target_level: int, prog: LevelProgression) -> None: def set_level(entity:Entity, target_level: int, prog: LevelProgression) -> None:
""" """
Snap an entity to a *specific* level. Sets XP to that level's floor. Snap an entity to a *specific* level. Sets XP to that level's floor.
@@ -25,12 +30,15 @@ def set_level(entity:Entity, target_level: int, prog: LevelProgression) -> None:
# set next level xp as xp needed for next level # set next level xp as xp needed for next level
entity.xp_to_next_level = prog.xp_for_level(target_level + 1) entity.xp_to_next_level = prog.xp_for_level(target_level + 1)
spells_list = newly_unlocked_abilities(class_name=entity.profession.name, # entity get's a random number of spells based on the number of levels gained
path="Hellknight", skills_per_level = calculate_skills_gained(current,target_level)
skills = newly_unlocked_abilities(class_name=entity.profession.name,
path=entity.ability_pathway,
level=target_level, level=target_level,
per_tier=1, per_tier=skills_per_level,
primary=entity.profession.primary_stat) primary=entity.profession.primary_stat)
_add_abilities(entity,spells_list) _add_abilities(entity,skills)
_recalc(entity) _recalc(entity)
@@ -46,12 +54,12 @@ def grant_xp(entity:Entity, amount: int, prog: LevelProgression) -> Tuple[int, i
if new_level > old_level: if new_level > old_level:
for L in range(old_level + 1, new_level + 1): for L in range(old_level + 1, new_level + 1):
spells_list = newly_unlocked_abilities(class_name=entity.profession.name, skills = newly_unlocked_abilities(class_name=entity.profession.name,
path="Hellknight", path=entity.ability_pathway,
level=new_level, level=new_level,
per_tier=1, per_tier=1,
primary=entity.profession.primary_stat) primary=entity.profession.primary_stat)
_add_abilities(entity,spells_list) _add_abilities(entity,skills)
if new_level > old_level: if new_level > old_level:
entity.level = new_level entity.level = new_level
@@ -70,6 +78,30 @@ def grant_xp(entity:Entity, amount: int, prog: LevelProgression) -> Tuple[int, i
# ---------- reward + recalc helpers ---------- # ---------- reward + recalc helpers ----------
def calculate_skills_gained(current_level: int, target_level: int, chance_per_level: float = GLOBAL_CHANCE_PER_LEVEL) -> int:
"""
Returns the number of new skills a player might gain
when leveling from current_level to target_level.
chance_per_level: probability (01) of gaining a skill per level.
Guarantees at least 2 skills for small level gains.
"""
levels_gained = max(0, target_level - current_level)
if levels_gained == 0:
return 0
# Simulate random gain per level
gained = sum(1 for _ in range(levels_gained) if random.random() < chance_per_level)
# Guarantee at least 2 skills if the player barely leveled
if levels_gained < 3:
gained = max(gained, 2)
# Smooth scaling: for huge jumps, don't explode linearly
# (cap roughly at 40% of total levels gained)
cap = math.ceil(levels_gained * 0.4)
return min(gained, cap)
def _add_abilities(entity:Entity, abilities_list:list) -> None: def _add_abilities(entity:Entity, abilities_list:list) -> None:
for ability in abilities_list: for ability in abilities_list:
entity.abilities.append(ability) entity.abilities.append(ability)

View File

@@ -10,32 +10,38 @@ Frostbinder:
nouns: ["Spike", "Lance", "Shard", "Burst", "Ray"] nouns: ["Spike", "Lance", "Shard", "Burst", "Ray"]
of_things: ["Frost", "Winter", "Stillness", "Silence", "Cold"] of_things: ["Frost", "Winter", "Stillness", "Silence", "Cold"]
Bloodborn: Khaosfire:
elements: ["curse", "shadow"] elements: ["curse", "shadow"]
adjectives: ["Tainted", "Cursed", "Corrupted", "Polluted", "Toxic", "Deadly"] adjectives: ["Tainted", "Cursed", "Corrupted", "Polluted", "Toxic", "Deadly"]
nouns: ["Poison", "Infection", "Wound", "Bane", "Plague", "Ravage", "Scourge"] nouns: ["Poison", "Infection", "Wound", "Bane", "Plague", "Ravage", "Scourge"]
of_things: ["Infectious", "Contagion", "Disease", "Fungus", "Spore", "Seeds"] of_things: ["Infectious", "Contagion", "Disease", "Fungus", "Spore", "Seeds"]
Assassin: Exsanguin:
elements: ["wind", "shadow"] elements: ["wind", "shadow"]
adjectives: ["Stealthy", "Deadly", "Ruthless", "Silent", "Lethal", "Fearsome"] adjectives: ["Stealthy", "Deadly", "Ruthless", "Silent", "Lethal", "Fearsome"]
nouns: ["Dagger", "Knife", "Poison", "Bolt", "Arrows", "Scimitar", "Twinblade"] nouns: ["Dagger", "Knife", "Poison", "Bolt", "Arrows", "Scimitar", "Twinblade"]
of_things: ["Hunter", "Stalker", "Predator", "Ghost", "Shadow", "Silhouette"] of_things: ["Hunter", "Stalker", "Predator", "Ghost", "Shadow", "Silhouette"]
Cleric: Sanctifier:
elements: ["fire", "light"] elements: ["fire", "light"]
adjectives: ["Holy", "Divine", "Pure", "Blessed", "Sacred", "Radiant"] adjectives: ["Holy", "Divine", "Pure", "Blessed", "Sacred", "Radiant"]
nouns: ["Flame", "Lightning", "Bolt", "Sword", "Shield", "Vocation", "Call"] nouns: ["Flame", "Lightning", "Bolt", "Sword", "Shield", "Vocation", "Call"]
of_things: ["Healing", "Protection", "Blessing", "Salvation", "Redemption", "Mercy"] of_things: ["Healing", "Protection", "Blessing", "Salvation", "Redemption", "Mercy"]
Hexist: Necromantia:
elements: ["curse", "shadow"] elements: ["curse", "shadow"]
adjectives: ["Dark", "Malefic", "Sinister", "Maledicta", "Witchlike", "Pestilential"] adjectives: ["Dark", "Malefic", "Sinister", "Maledicta", "Witchlike", "Pestilential"]
nouns: ["Spell", "Hex", "Curse", "Influence", "Taint", "Bane", "Malice"] nouns: ["Spell", "Hex", "Curse", "Influence", "Taint", "Bane", "Malice"]
of_things: ["Poison", "Deceit", "Demonic", "Abomination", "Evil", "Sinister"] of_things: ["Poison", "Deceit", "Demonic", "Abomination", "Evil", "Sinister"]
Ranger: Huntsman:
elements: ["air", "wind"] elements: ["air", "wind"]
adjectives: ["Wild", "Free", "Wanderer", "Survivor", "Huntress", "Skilled"] adjectives: ["Wild", "Free", "Wanderer", "Survivor", "Huntress", "Skilled"]
nouns: ["Arrows", "Rifle", "Bow", "Arrowhead", "Shotgun", "Crossbow", "Pistol"] nouns: ["Arrows", "Rifle", "Bow", "Arrowhead", "Shotgun", "Crossbow", "Pistol"]
of_things: ["Wolves", "Forest", "Wilderness", "Hunter", "Tracking", "Ambush"] of_things: ["Wolves", "Forest", "Wilderness", "Hunter", "Tracking", "Ambush"]
Kratosphere:
elements: ["stone", "earth"]
adjectives: ["Rocky", "Solid", "Unyielding", "Resilient", "Firm"]
nouns: ["Shield", "Spike", "Breach", "Barrier", "Fortress"]
of_things: ["Stonework", "Rockfall", "Stonefall", "Earthquake", "Bedrock"]

View File

@@ -8,4 +8,5 @@ physical_attack_per_level: 1.1
physical_defense_per_level: 1.1 physical_defense_per_level: 1.1
magic_attack_per_level: 1.7 magic_attack_per_level: 1.7
magic_defense_per_level: 1.6 magic_defense_per_level: 1.6
ability_paths: ['Frostbinder']
tags: [playable, mage] tags: [playable, mage]

View File

@@ -8,4 +8,5 @@ physical_attack_per_level: 1.8
physical_defense_per_level: 1.4 physical_defense_per_level: 1.4
magic_attack_per_level: 1.2 magic_attack_per_level: 1.2
magic_defense_per_level: 1.1 magic_defense_per_level: 1.1
ability_paths: ['Exsanguin']
tags: [playable, flex] tags: [playable, flex]

View File

@@ -8,4 +8,5 @@ physical_attack_per_level: 1.8
physical_defense_per_level: 1.4 physical_defense_per_level: 1.4
magic_attack_per_level: 1.2 magic_attack_per_level: 1.2
magic_defense_per_level: 1.1 magic_defense_per_level: 1.1
ability_paths: ['Khaosfire']
tags: [playable, martial] tags: [playable, martial]

View File

@@ -8,4 +8,5 @@ physical_attack_per_level: 1.1
physical_defense_per_level: 1.1 physical_defense_per_level: 1.1
magic_attack_per_level: 1.7 magic_attack_per_level: 1.7
magic_defense_per_level: 1.6 magic_defense_per_level: 1.6
ability_paths: ['Sanctifier']
tags: [playable, mage] tags: [playable, mage]

View File

@@ -8,4 +8,5 @@ physical_attack_per_level: 1.8
physical_defense_per_level: 1.4 physical_defense_per_level: 1.4
magic_attack_per_level: 1.2 magic_attack_per_level: 1.2
magic_defense_per_level: 1.1 magic_defense_per_level: 1.1
ability_paths: ['Kratosphere']
tags: [playable, martial] tags: [playable, martial]

View File

@@ -8,4 +8,5 @@ physical_attack_per_level: 1.1
physical_defense_per_level: 1.1 physical_defense_per_level: 1.1
magic_attack_per_level: 1.7 magic_attack_per_level: 1.7
magic_defense_per_level: 1.6 magic_defense_per_level: 1.6
ability_paths: ['Necromantia']
tags: [playable, mage] tags: [playable, mage]

View File

@@ -8,4 +8,5 @@ physical_attack_per_level: 1.8
physical_defense_per_level: 1.4 physical_defense_per_level: 1.4
magic_attack_per_level: 1.2 magic_attack_per_level: 1.2
magic_defense_per_level: 1.1 magic_defense_per_level: 1.1
ability_paths: ['Huntsman']
tags: [playable, flex] tags: [playable, flex]

View File

@@ -8,4 +8,5 @@ physical_attack_per_level: 1.1
physical_defense_per_level: 1.1 physical_defense_per_level: 1.1
magic_attack_per_level: 1.7 magic_attack_per_level: 1.7
magic_defense_per_level: 1.6 magic_defense_per_level: 1.6
ability_paths: ['Hellknight']
tags: [playable, mage] tags: [playable, mage]

291
docs/char_gen.md Normal file
View File

@@ -0,0 +1,291 @@
# 🧩 Code of Conquest — Character Generation (Flask API)
## Overview
This document describes how **character generation** operates inside the Flask API.
The goal is to produce a fully realized `Entity` (hero, enemy, or NPC) based on **race**, **profession**, **abilities**, and **level** using YAML-defined data and procedural logic.
---
## ⚙️ Directory Overview
```
app/
├── blueprints/
│ ├── main.py # Root routes
│ └── char.py # Character creation and progression endpoints
├── game/
│ ├── generators/ # Procedural content
│ │ ├── abilities_factory.py # Generates abilities / skills
│ │ ├── entity_factory.py # Builds complete entity objects
│ │ ├── level_progression.py # XP and level curve logic
│ │ └── ... (compiled files)
│ │
│ ├── loaders/ # YAML loaders for static templates
│ │ ├── profession_loader.py
│ │ ├── races_loader.py
│ │ └── spells_loader.py # May be deprecated in favor of skills
│ │
│ ├── models/ # Dataclasses for core game objects
│ │ ├── abilities.py
│ │ ├── entities.py
│ │ ├── professions.py
│ │ ├── races.py
│ │ └── enemies.py
│ │
│ ├── systems/
│ │ └── leveling.py # Handles XP gain, set_level, and skill growth
│ │
│ ├── templates/
│ │ ├── ability_paths.yaml # Defines thematic skill sets (paths)
│ │ ├── professions/*.yaml # Defines all professions
│ │ └── races/*.yaml # Defines all playable races
│ │
│ └── utils/
│ └── common.py
├── models/ # Shared models for the Flask layer
│ ├── hero.py
│ ├── enums.py
│ └── primitives.py
└── utils/
├── catalogs/ # Compiled catalog data for fast lookups
│ ├── race_catalog.py
│ ├── hero_catalog.py
│ └── skill_catalog.py
├── api_response.py
└── settings.py
```
---
## 🧬 Entity Generation Pipeline
### 1. `entity_factory.build_entity()`
Creates a base entity by combining **Race** and **Profession** definitions.
**Steps:**
1. Load race + profession data from YAML via loaders.
2. Apply racial ability modifiers.
3. Apply profession base stats and per-level growth rates.
4. Initialize HP, MP, XP, and skill lists.
---
### 2. `leveling.set_level(entity, target_level)`
Handles level setting and skill gain.
* Calculates XP using `level_progression.py`.
* Calls `calculate_skills_gained()` (randomized 30% chance per level).
* Generates new skills via `abilities_factory`.
---
## 🧝 Races
Located in `app/game/templates/races/`.
Each race defines:
* Base ability modifiers (STR, DEX, CON, INT, WIS, CHA)
* Optional descriptive text
* Tags for gameplay classification
Example (`elf.yaml`):
```yaml
name: Elf
description: Graceful and attuned to magic.
ability_mods:
DEX: 2
INT: 1
CON: -1
tags: ["playable"]
```
---
## ⚔️ Professions
Located in `app/game/templates/professions/`.
Each profession file defines:
* Base HP / MP
* Scaling per level
* Primary stat (INT, WIS, etc.)
* Flavor / lore fields
Example (`warlock.yaml`):
```yaml
id: warlock
name: Warlock
description: A wielder of forbidden demonic power.
primary_stat: CHA
base_hp: 40
base_mp: 80
physical_attack_per_level: 1.0
physical_defense_per_level: 0.5
magic_attack_per_level: 2.5
magic_defense_per_level: 1.5
tags: ["caster","dark"]
```
---
## 🧠 Ability / Skill Generation
### Path Themes
Defined in `templates/ability_paths.yaml`:
```yaml
Hellknight:
elements: ["fire", "shadow"]
adjectives: ["Infernal", "Hellfire", "Brimstone", "Abyssal"]
nouns: ["Edict", "Judgment", "Brand", "Smite"]
of_things: ["Dominion", "Torment", "Cinders", "Night"]
```
### Generator Function
Located in `abilities_factory.py`:
```python
def generate_spells_direct_damage(
class_name: str,
path: str,
level: int,
per_tier: int = 2,
content_version: int = 1,
primary: str = "INT"
) -> List[Spell]:
...
```
**Features:**
* Deterministic random seed based on `(class, path, level, version)`
* Generates direct-damage only
* Scales power and cost by level
* Names and effects are thematically consistent
### Example Output
```
Infernal Judgment — Deal fire damage (power 120, CHA+125%). MP 30, CD 1
Brand of Cinders — Deal shadow damage (power 118, CHA+125%). MP 30, CD 0
```
---
## 📈 Level Progression
Handled in `level_progression.py`.
* Defines XP thresholds per level (exponential curve)
* Provides helper: `get_xp_for_level(level)`
* Ensures `entity.xp_to_next_level` is always accurate after XP updates.
---
## 🎲 Skill Gain Logic
Implemented in `systems/leveling.py`.
```python
def calculate_skills_gained(current_level, target_level, chance_per_level=0.3):
levels_gained = max(0, target_level - current_level)
if levels_gained == 0:
return 0
gained = sum(1 for _ in range(levels_gained) if random.random() < chance_per_level)
if levels_gained < 3:
gained = max(gained, 2)
cap = math.ceil(levels_gained * 0.4)
return min(gained, cap)
```
* **30% random chance** per level to gain a new skill
* Guarantees **≥ 2** if leveling fewer than 3 levels
* Caps maximum growth to **40%** of total levels gained
---
## 🧮 XP / Level Curve Example
| Level | XP Needed (example) | XP Delta | Notes |
| ----- | ------------------- | -------- | ----------------- |
| 1 | 0 | — | Starting level |
| 2 | 100 | +100 | Fast early growth |
| 5 | 625 | +175 | Moderate |
| 10 | 2500 | +350 | Midgame plateau |
| 20 | 10000 | +750 | Late-game scaling |
*(Values may differ depending on `level_progression.py` formula)*
---
## ⚙️ API Integration
### Blueprint: `char.py`
Handles player-facing endpoints such as:
* `POST /char/create` → build entity from race + profession
* `POST /char/level` → level up existing entity and add new skills
* `GET /char/:id` → return current character stats, XP, and skills
Example:
```python
@char_bp.route("/create", methods=["POST"])
def create_character():
data = request.json
entity = build_entity(data["race"], data["profession"])
return jsonify(entity.to_dict())
```
---
## 📁 Data Flow Summary
```mermaid
graph TD
A[API Request] --> B[char.py Blueprint]
B --> C[entity_factory.build_entity()]
C --> D[races_loader / profession_loader]
C --> E[level_progression]
C --> F[abilities_factory]
F --> G[ability_paths.yaml]
C --> H[Entity Model]
H --> I[API Response JSON]
```
---
## 🧾 Design Notes
* All YAML templates are static assets — easy to edit without code changes.
* Procedural factories (`abilities_factory`, `entity_factory`) ensure deterministic generation for the same input.
* Systems (`leveling.py`) handle simulation and logic, isolated from API routes.
* `utils/catalogs` may eventually cache or precompute available skills, professions, and races for faster response times.
* The entire pipeline can operate **statelessly** inside Flask routes or **persisted** in a database later.
---
## 🔮 Future Ideas
* Add **passive traits** per race or profession.
* Implement **rarity** or **tiered skills** (common → legendary).
* Generate **enemy entities** using the same system with difficulty scaling.
* Add a **training system** or **skill mastery** mechanic tied to XP.
* Create **hero_catalog.py** entries for pre-built templates used in story mode.

View File

@@ -12,15 +12,14 @@ 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",
# profession_id="arcanist", profession_id="arcanist",
profession_id="guardian", ability_pathway="Frostbinder",
level=3 level=50
) )
old, new = grant_xp(player,(156),DEFAULT_LEVEL_PROGRESSION) old, new = grant_xp(player,(156),DEFAULT_LEVEL_PROGRESSION)
player_dict = asdict(player) player_dict = asdict(player)
print(json.dumps(player_dict,indent=True)) # print(json.dumps(player_dict,indent=True))
exit() exit()
# MOVE HIT DICE TO WEAPONS! # MOVE HIT DICE TO WEAPONS!