adding weapon gen
This commit is contained in:
226
app/game/generators/weapons_factory.py
Normal file
226
app/game/generators/weapons_factory.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import os, glob, random, uuid, json
|
||||||
|
from dataclasses import asdict
|
||||||
|
from typing import Any, Dict, List, Tuple, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.game.models.weapons import Weapon
|
||||||
|
|
||||||
|
|
||||||
|
WEAPON_TEMPLATE_PATH = os.path.join("app","game","templates","weapons")
|
||||||
|
|
||||||
|
# ---------------- Loader ----------------
|
||||||
|
|
||||||
|
def load_config_dir(dir_path: str = WEAPON_TEMPLATE_PATH) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load and merge the split YAML files from templates/weapons/.
|
||||||
|
Expected keys across files: rarities, bases, affixes, implicits, grammar, balance, economy.
|
||||||
|
"""
|
||||||
|
if not os.path.isdir(dir_path):
|
||||||
|
raise FileNotFoundError(f"Directory not found: {dir_path}")
|
||||||
|
|
||||||
|
cfg: Dict[str, Any] = {}
|
||||||
|
# Deterministic load order helps avoid surprises
|
||||||
|
order = [
|
||||||
|
"rarities.yaml",
|
||||||
|
"bases.yaml",
|
||||||
|
"affixes.yaml",
|
||||||
|
"implicits.yaml",
|
||||||
|
"grammar.yaml",
|
||||||
|
"tuning.yaml",
|
||||||
|
]
|
||||||
|
filenames = [os.path.join(dir_path, f) for f in order if os.path.exists(os.path.join(dir_path, f))]
|
||||||
|
if not filenames:
|
||||||
|
# Fallback to load all .yaml if custom names were used
|
||||||
|
filenames = sorted(glob.glob(os.path.join(dir_path, "*.yaml")))
|
||||||
|
|
||||||
|
for path in filenames:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
# shallow merge by top-level keys
|
||||||
|
for k, v in data.items():
|
||||||
|
if k in cfg and isinstance(cfg[k], dict) and isinstance(v, dict):
|
||||||
|
cfg[k].update(v)
|
||||||
|
else:
|
||||||
|
cfg[k] = v
|
||||||
|
|
||||||
|
# Minimal validation
|
||||||
|
required = ["rarities", "bases", "affixes", "grammar", "balance", "economy"]
|
||||||
|
missing = [k for k in required if k not in cfg]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Missing required sections in merged config: {missing}")
|
||||||
|
|
||||||
|
# Normalize shapes used by the generator
|
||||||
|
# affixes should contain 'prefixes' and 'suffixes'
|
||||||
|
if "prefixes" not in cfg["affixes"] or "suffixes" not in cfg["affixes"]:
|
||||||
|
raise ValueError("affixes.yaml must contain 'prefixes' and 'suffixes' lists.")
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Generator ----------------
|
||||||
|
|
||||||
|
class WeaponGenerator:
|
||||||
|
def __init__(self, rng: Optional[random.Random] = None):
|
||||||
|
self.cfg = load_config_dir()
|
||||||
|
self.rng = rng or random.Random()
|
||||||
|
self._rarities = list(self.cfg["rarities"].keys())
|
||||||
|
self._rarity_weights = [self.cfg["rarities"][r]["weight"] for r in self._rarities]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _roll_float(rng: random.Random, lo: float, hi: float) -> float:
|
||||||
|
return rng.uniform(lo, hi)
|
||||||
|
|
||||||
|
def _weighted_choice(self, pairs: List[Tuple[Any, int]]):
|
||||||
|
items, weights = zip(*pairs)
|
||||||
|
return self.rng.choices(items, weights=weights, k=1)[0]
|
||||||
|
|
||||||
|
def _choose_rarity(self) -> str:
|
||||||
|
return self.rng.choices(self._rarities, weights=self._rarity_weights, k=1)[0]
|
||||||
|
|
||||||
|
def _choose_base(self) -> Dict[str, Any]:
|
||||||
|
return self.rng.choice(self.cfg["bases"])
|
||||||
|
|
||||||
|
def _roll_item_level(self, char_level: int) -> int:
|
||||||
|
off = self.rng.randint(self.cfg["balance"]["item_level_offset"]["min"],
|
||||||
|
self.cfg["balance"]["item_level_offset"]["max"])
|
||||||
|
lvl = max(self.cfg["balance"]["min_item_level"], char_level + off)
|
||||||
|
return max(1, int(lvl))
|
||||||
|
|
||||||
|
def _scale_base_damage(self, base_min: int, base_max: int, item_level: int) -> Tuple[float, float]:
|
||||||
|
g = self.cfg["balance"]["base_damage_growth_per_level"]
|
||||||
|
growth = (1.0 + g) ** max(item_level - 1, 0)
|
||||||
|
return base_min * growth, base_max * growth
|
||||||
|
|
||||||
|
def _roll_affix_block(self, items: List[Dict[str, Any]], count: int, item_level: int) -> Tuple[List[Dict], Dict[str, float]]:
|
||||||
|
chosen, stats = [], {}
|
||||||
|
pool = items[:]
|
||||||
|
self.rng.shuffle(pool)
|
||||||
|
for pick in pool:
|
||||||
|
if len(chosen) >= count:
|
||||||
|
break
|
||||||
|
rolled = []
|
||||||
|
for eff in pick.get("effects", []):
|
||||||
|
lo, hi = eff["min"], eff["max"]
|
||||||
|
per_lv = eff.get("per_level", 0.0)
|
||||||
|
val = self._roll_float(self.rng, lo, hi) + per_lv * item_level
|
||||||
|
rolled.append({"stat": eff["stat"], "value": val})
|
||||||
|
stats[eff["stat"]] = stats.get(eff["stat"], 0.0) + val
|
||||||
|
chosen.append({"id": pick["id"], "name": pick["name"], "effects": rolled})
|
||||||
|
return chosen, stats
|
||||||
|
|
||||||
|
def _apply_implicits(self, rarity: str, item_level: int) -> Tuple[List[Dict], Dict[str, float]]:
|
||||||
|
table = self.cfg.get("implicits", {}).get(rarity, [])
|
||||||
|
if not table:
|
||||||
|
return [], {}
|
||||||
|
imp = self.rng.choice(table)
|
||||||
|
rolled, stats = [], {}
|
||||||
|
for eff in imp.get("effects", []):
|
||||||
|
val = self._roll_float(self.rng, eff["min"], eff["max"]) + eff.get("per_level", 0.0) * item_level
|
||||||
|
rolled.append({"stat": eff["stat"], "value": val})
|
||||||
|
stats[eff["stat"]] = stats.get(eff["stat"], 0.0) + val
|
||||||
|
return [{"id": imp["id"], "name": imp["name"], "effects": rolled}], stats
|
||||||
|
|
||||||
|
def _compose_name(self, base_name: str, rarity: str, aff_prefixes: List[Dict], aff_suffixes: List[Dict]) -> str:
|
||||||
|
g = self.cfg["grammar"]
|
||||||
|
prefix_primary = aff_prefixes[0]["name"] if aff_prefixes else ""
|
||||||
|
suffix_primary = aff_suffixes[0]["name"] if aff_suffixes else ""
|
||||||
|
rarity_title = self.cfg["rarities"][rarity]["title"]
|
||||||
|
|
||||||
|
token_values = {
|
||||||
|
"rarity_title_opt": (rarity_title + " ") if rarity_title else "",
|
||||||
|
"prefix_primary_opt": (prefix_primary + " ") if prefix_primary else "",
|
||||||
|
"suffix_primary_opt": (" " + suffix_primary) if suffix_primary else "",
|
||||||
|
"base_name": base_name,
|
||||||
|
"crafted_by": self.rng.choice(g["lists"]["crafted_by"]),
|
||||||
|
"mythic_noun": self.rng.choice(g["lists"]["mythic_noun"]),
|
||||||
|
"epithet": self.rng.choice(g["lists"]["epithet"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns = [(p["template"], p["weight"]) for p in g["patterns"]]
|
||||||
|
template = self._weighted_choice(patterns)
|
||||||
|
name = template
|
||||||
|
for k, v in token_values.items():
|
||||||
|
name = name.replace("{" + k + "}", v)
|
||||||
|
name = " ".join(name.split()).replace(" ,", ",")
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _compute_value(self, rarity: str, item_level: int, char_level: int, dmg_min: float, dmg_max: float, stats: Dict[str, float]) -> int:
|
||||||
|
avg_dmg = 0.5 * (dmg_min + dmg_max)
|
||||||
|
crit = stats.get("crit_chance_pct", 0.0) * 0.5
|
||||||
|
elem = stats.get("fire_damage_flat", 0.0) * 0.8
|
||||||
|
flat = stats.get("flat_damage", 0.0)
|
||||||
|
item_power = avg_dmg + crit + elem + flat
|
||||||
|
|
||||||
|
rarity_mul = self.cfg["rarities"][rarity]["value_multiplier"]
|
||||||
|
raw_value = item_power * rarity_mul
|
||||||
|
|
||||||
|
econ = self.cfg["economy"]
|
||||||
|
delta = max(char_level, 1) - max(item_level, 1)
|
||||||
|
|
||||||
|
if delta > 0:
|
||||||
|
mult = (max(item_level, 1) / max(char_level, 1)) ** econ["underlevel_elasticity"]
|
||||||
|
adj = max(mult, econ["min_sell_floor"] / max(raw_value, econ["min_sell_floor"]))
|
||||||
|
elif delta < 0:
|
||||||
|
over = min(abs(delta) * 0.05, econ["overlevel_bonus_pct"])
|
||||||
|
adj = 1.0 + over
|
||||||
|
else:
|
||||||
|
adj = 1.0
|
||||||
|
|
||||||
|
return int(max(econ["min_sell_floor"], raw_value * adj))
|
||||||
|
|
||||||
|
def generate(self, char_level: int) -> Dict[str, Any]:
|
||||||
|
rarity = self._choose_rarity()
|
||||||
|
base = self._choose_base()
|
||||||
|
item_level = self._roll_item_level(char_level)
|
||||||
|
|
||||||
|
smin, smax = self._scale_base_damage(base["min_dmg"], base["max_dmg"], item_level)
|
||||||
|
|
||||||
|
rdef = self.cfg["rarities"][rarity]
|
||||||
|
pslots, sslots = rdef["prefix_slots"], rdef["suffix_slots"]
|
||||||
|
|
||||||
|
prefixes, pstats = self._roll_affix_block(self.cfg["affixes"]["prefixes"], pslots, item_level)
|
||||||
|
suffixes, sstats = self._roll_affix_block(self.cfg["affixes"]["suffixes"], sslots, item_level)
|
||||||
|
implicits, istats = self._apply_implicits(rarity, item_level)
|
||||||
|
|
||||||
|
stats: Dict[str, float] = {}
|
||||||
|
for source in (pstats, sstats, istats):
|
||||||
|
for k, v in source.items():
|
||||||
|
stats[k] = stats.get(k, 0.0) + v
|
||||||
|
|
||||||
|
rarity_mul = rdef["dmg_multiplier"]
|
||||||
|
flat = stats.get("flat_damage", 0.0)
|
||||||
|
min_dmg = (smin + flat) * rarity_mul
|
||||||
|
max_dmg = (smax + flat) * rarity_mul
|
||||||
|
|
||||||
|
name = self._compose_name(base["name"], rarity, prefixes, suffixes)
|
||||||
|
value_sell = self._compute_value(rarity, item_level, char_level, min_dmg, max_dmg, stats)
|
||||||
|
|
||||||
|
w = Weapon(
|
||||||
|
uid=str(uuid.uuid4()),
|
||||||
|
item_level=item_level,
|
||||||
|
char_level=char_level,
|
||||||
|
base_id=base["id"],
|
||||||
|
base_name=base["name"],
|
||||||
|
rarity=rarity,
|
||||||
|
name=name,
|
||||||
|
min_dmg=int(round(min_dmg)),
|
||||||
|
max_dmg=int(round(max_dmg)),
|
||||||
|
speed=float(base["speed"]),
|
||||||
|
affixes=prefixes + suffixes,
|
||||||
|
implicits=implicits,
|
||||||
|
stats={k: round(v, 2) for k, v in stats.items()},
|
||||||
|
value_sell=value_sell,
|
||||||
|
)
|
||||||
|
return asdict(w)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Example CLI ----------------
|
||||||
|
|
||||||
|
# if __name__ == "__main__":
|
||||||
|
# cfg = load_config_dir("templates/weapons")
|
||||||
|
# gen = WeaponGenerator(cfg)
|
||||||
|
# for lvl in (1, 5, 15, 30):
|
||||||
|
# item = gen.generate(char_level=lvl)
|
||||||
|
# print(json.dumps(item, indent=2))
|
||||||
@@ -53,5 +53,4 @@ class Entity:
|
|||||||
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)
|
||||||
abilities: List[Ability] = field(default_factory=list)
|
abilities: List[Ability] = field(default_factory=list)
|
||||||
skills: List[Dict[str, int]] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|||||||
21
app/game/models/weapons.py
Normal file
21
app/game/models/weapons.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import Any, Dict, List, Tuple, Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Weapon:
|
||||||
|
uid: str
|
||||||
|
item_level: int
|
||||||
|
char_level: int
|
||||||
|
base_id: str
|
||||||
|
base_name: str
|
||||||
|
rarity: str
|
||||||
|
name: str
|
||||||
|
min_dmg: int
|
||||||
|
max_dmg: int
|
||||||
|
speed: float
|
||||||
|
affixes: List[Dict[str, Any]]
|
||||||
|
implicits: List[Dict[str, Any]]
|
||||||
|
stats: Dict[str, float]
|
||||||
|
value_sell: int
|
||||||
47
app/game/templates/weapons/affixes.yaml
Normal file
47
app/game/templates/weapons/affixes.yaml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Prefixes and suffixes. Add tags_required/tags_forbidden if desired.
|
||||||
|
affixes:
|
||||||
|
prefixes:
|
||||||
|
- id: flaming
|
||||||
|
name: Flaming
|
||||||
|
effects:
|
||||||
|
- stat: fire_damage_flat
|
||||||
|
min: 1
|
||||||
|
max: 4
|
||||||
|
per_level: 0.15
|
||||||
|
- id: keen
|
||||||
|
name: Keen
|
||||||
|
effects:
|
||||||
|
- stat: crit_chance_pct
|
||||||
|
min: 2
|
||||||
|
max: 6
|
||||||
|
per_level: 0.08
|
||||||
|
- id: vicious
|
||||||
|
name: Vicious
|
||||||
|
effects:
|
||||||
|
- stat: flat_damage
|
||||||
|
min: 1
|
||||||
|
max: 3
|
||||||
|
per_level: 0.20
|
||||||
|
|
||||||
|
suffixes:
|
||||||
|
- id: of_strength
|
||||||
|
name: of Strength
|
||||||
|
effects:
|
||||||
|
- stat: str
|
||||||
|
min: 1
|
||||||
|
max: 2
|
||||||
|
per_level: 0.06
|
||||||
|
- id: of_speed
|
||||||
|
name: of Speed
|
||||||
|
effects:
|
||||||
|
- stat: dex
|
||||||
|
min: 1
|
||||||
|
max: 2
|
||||||
|
per_level: 0.06
|
||||||
|
- id: of_the_owl
|
||||||
|
name: of the Owl
|
||||||
|
effects:
|
||||||
|
- stat: int
|
||||||
|
min: 1
|
||||||
|
max: 2
|
||||||
|
per_level: 0.06
|
||||||
20
app/game/templates/weapons/bases.yaml
Normal file
20
app/game/templates/weapons/bases.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Base weapon templates (scaled by item_level & rarity)
|
||||||
|
bases:
|
||||||
|
- id: dagger
|
||||||
|
name: Dagger
|
||||||
|
min_dmg: 2
|
||||||
|
max_dmg: 6
|
||||||
|
speed: 1.20
|
||||||
|
tags: [light, blade]
|
||||||
|
- id: sword
|
||||||
|
name: Sword
|
||||||
|
min_dmg: 4
|
||||||
|
max_dmg: 10
|
||||||
|
speed: 1.00
|
||||||
|
tags: [blade]
|
||||||
|
- id: mace
|
||||||
|
name: Mace
|
||||||
|
min_dmg: 6
|
||||||
|
max_dmg: 12
|
||||||
|
speed: 0.90
|
||||||
|
tags: [blunt]
|
||||||
29
app/game/templates/weapons/grammar.yaml
Normal file
29
app/game/templates/weapons/grammar.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Flavorful name grammar
|
||||||
|
grammar:
|
||||||
|
patterns:
|
||||||
|
- weight: 40
|
||||||
|
template: "{rarity_title_opt}{prefix_primary_opt}{base_name}{suffix_primary_opt}"
|
||||||
|
- weight: 25
|
||||||
|
template: "{rarity_title_opt}{base_name} of the {mythic_noun}"
|
||||||
|
- weight: 20
|
||||||
|
template: "{prefix_primary_opt}{base_name}, {crafted_by}"
|
||||||
|
- weight: 15
|
||||||
|
template: "{prefix_primary_opt}{base_name} — {epithet}"
|
||||||
|
|
||||||
|
lists:
|
||||||
|
crafted_by:
|
||||||
|
- "Forged by the Vale"
|
||||||
|
- "Tempered in Night"
|
||||||
|
- "Hewn by Old Kings"
|
||||||
|
- "Wrought in Emberforge"
|
||||||
|
mythic_noun:
|
||||||
|
- Phoenix
|
||||||
|
- Tempest
|
||||||
|
- First Dawn
|
||||||
|
- Last King
|
||||||
|
- Black Sun
|
||||||
|
epithet:
|
||||||
|
- "The Ashmaker"
|
||||||
|
- "Whisper of Steel"
|
||||||
|
- "Oathbreaker"
|
||||||
|
- "Heartpiercer"
|
||||||
22
app/game/templates/weapons/implicits.yaml
Normal file
22
app/game/templates/weapons/implicits.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Optional auto-added mods for higher rarities
|
||||||
|
implicits:
|
||||||
|
exalted:
|
||||||
|
- id: exalted_shard
|
||||||
|
name: Exalted
|
||||||
|
effects:
|
||||||
|
- stat: flat_damage
|
||||||
|
min: 3
|
||||||
|
max: 6
|
||||||
|
per_level: 0.25
|
||||||
|
legendary:
|
||||||
|
- id: legendary_core
|
||||||
|
name: Legendary
|
||||||
|
effects:
|
||||||
|
- stat: flat_damage
|
||||||
|
min: 4
|
||||||
|
max: 8
|
||||||
|
per_level: 0.35
|
||||||
|
- stat: crit_chance_pct
|
||||||
|
min: 3
|
||||||
|
max: 8
|
||||||
|
per_level: 0.10
|
||||||
37
app/game/templates/weapons/rarities.yaml
Normal file
37
app/game/templates/weapons/rarities.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Rarity definitions & slot budgets
|
||||||
|
rarities:
|
||||||
|
common:
|
||||||
|
weight: 60
|
||||||
|
title: ""
|
||||||
|
dmg_multiplier: 1.00
|
||||||
|
prefix_slots: 0
|
||||||
|
suffix_slots: 0
|
||||||
|
value_multiplier: 1.0
|
||||||
|
magic:
|
||||||
|
weight: 25
|
||||||
|
title: ""
|
||||||
|
dmg_multiplier: 1.05
|
||||||
|
prefix_slots: 1
|
||||||
|
suffix_slots: 0
|
||||||
|
value_multiplier: 1.2
|
||||||
|
rare:
|
||||||
|
weight: 10
|
||||||
|
title: ""
|
||||||
|
dmg_multiplier: 1.12
|
||||||
|
prefix_slots: 1
|
||||||
|
suffix_slots: 1
|
||||||
|
value_multiplier: 1.5
|
||||||
|
exalted:
|
||||||
|
weight: 4
|
||||||
|
title: "Exalted"
|
||||||
|
dmg_multiplier: 1.25
|
||||||
|
prefix_slots: 2
|
||||||
|
suffix_slots: 1
|
||||||
|
value_multiplier: 2.2
|
||||||
|
legendary:
|
||||||
|
weight: 1
|
||||||
|
title: "Legendary"
|
||||||
|
dmg_multiplier: 1.40
|
||||||
|
prefix_slots: 2
|
||||||
|
suffix_slots: 2
|
||||||
|
value_multiplier: 3.0
|
||||||
12
app/game/templates/weapons/tuning.yaml
Normal file
12
app/game/templates/weapons/tuning.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Level scaling and economy knobs
|
||||||
|
balance:
|
||||||
|
base_damage_growth_per_level: 0.035
|
||||||
|
item_level_offset:
|
||||||
|
min: -3
|
||||||
|
max: +2
|
||||||
|
min_item_level: 1
|
||||||
|
|
||||||
|
economy:
|
||||||
|
min_sell_floor: 8
|
||||||
|
overlevel_bonus_pct: 0.10
|
||||||
|
underlevel_elasticity: 1.35
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
class HeroClass(str, Enum):
|
|
||||||
GUARDIAN = "guardian"
|
|
||||||
BLOODBORN = "bloodborn"
|
|
||||||
RANGER = "ranger"
|
|
||||||
ASSASSIN = "assassin"
|
|
||||||
ARCANIST = "arcanist"
|
|
||||||
HEXIST = "hexist"
|
|
||||||
CLERIC = "cleric"
|
|
||||||
WARLOCK = "warlock"
|
|
||||||
|
|
||||||
class Races(str, Enum):
|
|
||||||
"""Representing various species."""
|
|
||||||
Draconian = "draconian"
|
|
||||||
Dwarf = "dwarves"
|
|
||||||
Elf = "elves"
|
|
||||||
Beastfolk = "beastfolk"
|
|
||||||
Terran = "terran"
|
|
||||||
Vorgath = "vorgath"
|
|
||||||
Avaline = "avaline"
|
|
||||||
Hellion = "hellions"
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from dataclasses import dataclass, field, asdict
|
|
||||||
from typing import Callable, List, Optional, Dict, Any, Iterable, Mapping, TYPE_CHECKING
|
|
||||||
|
|
||||||
from app.models.enums import HeroClass, Race
|
|
||||||
from app.models.primitives import Attributes, Resources, Collections
|
|
||||||
from app.utils.merging import merge_catalogs, merge_sheets
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.utils.hero_catalog import HeroDataRegistry
|
|
||||||
from app.utils.race_catalog import RaceDataRegistry
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Small utilities
|
|
||||||
# -----------------------------
|
|
||||||
|
|
||||||
def _utcnow_iso() -> str:
|
|
||||||
"""Return current UTC time as ISO 8601 string."""
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
def _clamp(value: int, lo: int, hi: int) -> int:
|
|
||||||
"""Clamp integer between lo and hi."""
|
|
||||||
return max(lo, min(hi, value))
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Component dataclasses
|
|
||||||
# -----------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Progress:
|
|
||||||
"""Economy, experience, level, and other progression flags."""
|
|
||||||
level: int = 1
|
|
||||||
gold: int = 50
|
|
||||||
xp: int = 0
|
|
||||||
xp_next_level: int = 100 # will be recomputed on init
|
|
||||||
needs_sleep: int = 0
|
|
||||||
fame: int = 0
|
|
||||||
alignment: int = 0 # -100..+100 works nicely
|
|
||||||
battlecount: int = 0
|
|
||||||
|
|
||||||
def gain_xp(self, amount: int, xp_curve: Callable[[int, int], int]) -> int:
|
|
||||||
"""
|
|
||||||
Add XP and compute how many levels are gained using the provided xp curve.
|
|
||||||
Returns the number of levels gained.
|
|
||||||
"""
|
|
||||||
amount = max(0, int(amount))
|
|
||||||
if amount == 0:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
self.xp += amount
|
|
||||||
levels_gained = 0
|
|
||||||
|
|
||||||
# Level up loop (handles large XP chunks)
|
|
||||||
while self.xp >= self.xp_next_level:
|
|
||||||
self.level += 1
|
|
||||||
levels_gained += 1
|
|
||||||
self.xp_next_level = xp_curve(self.xp, self.level)
|
|
||||||
|
|
||||||
return levels_gained
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Location:
|
|
||||||
current_town: str = "Brindlemark"
|
|
||||||
current_nation: str = "Brindlemark"
|
|
||||||
current_continent: str = "Brindlemark"
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Equipment:
|
|
||||||
"""References to equipped items (IDs or simple names for now)."""
|
|
||||||
weapon: Optional[str] = None
|
|
||||||
armor: Optional[str] = None
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Main model
|
|
||||||
# -----------------------------
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Hero:
|
|
||||||
# Identity
|
|
||||||
hero_id: UUID = field(default_factory=uuid4)
|
|
||||||
name: str = "Unnamed"
|
|
||||||
|
|
||||||
# Build
|
|
||||||
race: Optional[Race] = None
|
|
||||||
hero_class: Optional[HeroClass] = None
|
|
||||||
|
|
||||||
# Stats & state
|
|
||||||
attributes: Attributes = field(default_factory=Attributes)
|
|
||||||
resources: Resources = field(default_factory=Resources)
|
|
||||||
progress: Progress = field(default_factory=Progress)
|
|
||||||
location: Location = field(default_factory=Location)
|
|
||||||
equipment: Equipment = field(default_factory=Equipment)
|
|
||||||
collections: Collections = field(default_factory=Collections)
|
|
||||||
|
|
||||||
# RP bits
|
|
||||||
origin_story: str = ""
|
|
||||||
|
|
||||||
# Auditing & versioning
|
|
||||||
version: int = 1
|
|
||||||
created_at: str = field(default_factory=_utcnow_iso)
|
|
||||||
updated_at: str = field(default_factory=_utcnow_iso)
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# Lifecycle helpers
|
|
||||||
# -------------------------
|
|
||||||
|
|
||||||
def initialize(self, xp_curve: Callable[[int, int], int]) -> None:
|
|
||||||
"""
|
|
||||||
Call once after creation or after loading if you need to recompute derived fields.
|
|
||||||
For example: set xp_next_level using your curve.
|
|
||||||
"""
|
|
||||||
self.progress.xp_next_level = xp_curve(self.progress.xp, self.progress.level)
|
|
||||||
self.touch()
|
|
||||||
|
|
||||||
def touch(self) -> None:
|
|
||||||
"""Update the `updated_at` timestamp."""
|
|
||||||
self.updated_at = _utcnow_iso()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_race_and_class(
|
|
||||||
cls,
|
|
||||||
name: str,
|
|
||||||
race: Race | None,
|
|
||||||
hero_class: HeroClass | None,
|
|
||||||
class_registry: HeroDataRegistry | None = None,
|
|
||||||
race_registry: RaceDataRegistry | None = None,
|
|
||||||
*,
|
|
||||||
# Optional global registries (if you’ve split catalogs):
|
|
||||||
skill_registry: Any | None = None,
|
|
||||||
spell_registry: Any | None = None,
|
|
||||||
) -> Hero:
|
|
||||||
"""
|
|
||||||
Factory that builds a Hero and seeds starting skills/spells from race/class.
|
|
||||||
Validates starting IDs against global registries when provided, otherwise
|
|
||||||
falls back to merged local catalogs from the race/class sheets.
|
|
||||||
"""
|
|
||||||
# Always define locals so later checks are safe
|
|
||||||
skills_catalog: Dict[str, Any] = {}
|
|
||||||
spells_catalog: Dict[str, Any] = {}
|
|
||||||
race_sheet = None
|
|
||||||
cls_sheet = None
|
|
||||||
|
|
||||||
if class_registry and race_registry and hero_class and race:
|
|
||||||
cls_sheet = class_registry.for_class(hero_class)
|
|
||||||
race_sheet = race_registry.for_race(race)
|
|
||||||
# Merge any local catalogs for fallback validation
|
|
||||||
skills_catalog, spells_catalog = merge_catalogs(
|
|
||||||
race_sheet.skills, race_sheet.spells,
|
|
||||||
cls_sheet.skills, cls_sheet.spells
|
|
||||||
)
|
|
||||||
|
|
||||||
starting_skills: list[str] = []
|
|
||||||
starting_spells: list[str] = []
|
|
||||||
if race_sheet:
|
|
||||||
starting_skills.extend(race_sheet.starting_skills or [])
|
|
||||||
starting_spells.extend(race_sheet.starting_spells or [])
|
|
||||||
if cls_sheet:
|
|
||||||
starting_skills.extend(cls_sheet.starting_skills or [])
|
|
||||||
starting_spells.extend(cls_sheet.starting_spells or [])
|
|
||||||
|
|
||||||
# Dedup while preserving order
|
|
||||||
def _dedup(seq: list[str]) -> list[str]:
|
|
||||||
seen: set[str] = set()
|
|
||||||
out: list[str] = []
|
|
||||||
for x in seq:
|
|
||||||
if x not in seen:
|
|
||||||
out.append(x)
|
|
||||||
seen.add(x)
|
|
||||||
return out
|
|
||||||
|
|
||||||
starting_skills = _dedup(starting_skills)
|
|
||||||
starting_spells = _dedup(starting_spells)
|
|
||||||
|
|
||||||
# Validate against global registries first, fallback to locals
|
|
||||||
_validate_ids_exist(
|
|
||||||
starting_skills, skills_catalog, "skill",
|
|
||||||
owner=f"{race.value if race else 'unknown'}+{hero_class.value if hero_class else 'unknown'}",
|
|
||||||
registry=skill_registry
|
|
||||||
)
|
|
||||||
_validate_ids_exist(
|
|
||||||
starting_spells, spells_catalog, "spell",
|
|
||||||
owner=f"{race.value if race else 'unknown'}+{hero_class.value if hero_class else 'unknown'}",
|
|
||||||
registry=spell_registry
|
|
||||||
)
|
|
||||||
|
|
||||||
hero = cls(name=name, race=race, hero_class=hero_class)
|
|
||||||
hero.collections.skills = starting_skills
|
|
||||||
hero.collections.spells = starting_spells
|
|
||||||
hero.touch()
|
|
||||||
return hero
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# Progression
|
|
||||||
# -------------------------
|
|
||||||
|
|
||||||
def gain_xp(
|
|
||||||
self,
|
|
||||||
amount: int,
|
|
||||||
xp_curve: Callable[[int, int], int],
|
|
||||||
class_registry: HeroDataRegistry | None = None,
|
|
||||||
race_registry: RaceDataRegistry | None = None,
|
|
||||||
*,
|
|
||||||
# Optional global registries (preferred for validation)
|
|
||||||
skill_registry: Any | None = None,
|
|
||||||
spell_registry: Any | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Gain XP and apply level-ups via the provided xp_curve.
|
|
||||||
If registries are provided, also grant any class/race skills & spells
|
|
||||||
unlocked by the newly reached level(s).
|
|
||||||
Returns number of levels gained.
|
|
||||||
"""
|
|
||||||
prev_level = self.progress.level
|
|
||||||
levels = self.progress.gain_xp(amount, xp_curve)
|
|
||||||
|
|
||||||
# Always prepare empty local catalogs; may be filled below
|
|
||||||
skills_catalog: Dict[str, Any] = {}
|
|
||||||
spells_catalog: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
if levels > 0 and class_registry and race_registry and self.hero_class and self.race:
|
|
||||||
cls_sheet = class_registry.for_class(self.hero_class)
|
|
||||||
race_sheet = race_registry.for_race(self.race)
|
|
||||||
|
|
||||||
# Optional: merge local catalogs for fallback validation
|
|
||||||
skills_catalog, spells_catalog = merge_catalogs(
|
|
||||||
race_sheet.skills, race_sheet.spells,
|
|
||||||
cls_sheet.skills, cls_sheet.spells
|
|
||||||
)
|
|
||||||
|
|
||||||
unlocked_skills: list[str] = []
|
|
||||||
unlocked_spells: list[str] = []
|
|
||||||
now_lvl = self.progress.level
|
|
||||||
|
|
||||||
# Unlock skills whose min_level is newly reached
|
|
||||||
for nodes in (cls_sheet.skill_trees or {}).values():
|
|
||||||
for ref in nodes:
|
|
||||||
if ref.min_level is not None and prev_level < ref.min_level <= now_lvl:
|
|
||||||
unlocked_skills.append(ref.id)
|
|
||||||
|
|
||||||
# Unlock spells by each level reached in the jump (handles 1→6, etc.)
|
|
||||||
for L in range(prev_level + 1, now_lvl + 1):
|
|
||||||
unlocked_spells.extend((cls_sheet.spells_by_level.get(L) or []))
|
|
||||||
|
|
||||||
# Validate using global registries when provided, fallback to locals
|
|
||||||
owner = f"{self.race.value}+{self.hero_class.value}"
|
|
||||||
_validate_ids_exist(unlocked_skills, skills_catalog, "skill", owner, registry=skill_registry)
|
|
||||||
_validate_ids_exist(unlocked_spells, spells_catalog, "spell", owner, registry=spell_registry)
|
|
||||||
|
|
||||||
# Grant only new ones (dedupe)
|
|
||||||
_unique_extend(self.collections.skills, unlocked_skills)
|
|
||||||
_unique_extend(self.collections.spells, unlocked_spells)
|
|
||||||
|
|
||||||
# Your existing resource gains
|
|
||||||
hp_gain = 8 * levels
|
|
||||||
mp_gain = 6 * levels
|
|
||||||
self.resources.maxhp += hp_gain
|
|
||||||
self.resources.maxmp += mp_gain
|
|
||||||
self.resources.hp = self.resources.maxhp
|
|
||||||
self.resources.mp = self.resources.maxmp
|
|
||||||
|
|
||||||
self.touch()
|
|
||||||
return levels
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# (De)serialization helpers
|
|
||||||
# -------------------------
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Convert to a pure-JSON-friendly dict (Enums → value, UUID → str).
|
|
||||||
"""
|
|
||||||
def _enum_to_value(obj: Any) -> Any:
|
|
||||||
if isinstance(obj, Enum):
|
|
||||||
return obj.value
|
|
||||||
if isinstance(obj, UUID):
|
|
||||||
return str(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
raw = asdict(self)
|
|
||||||
# Fix enum & UUID fields:
|
|
||||||
raw["hero_id"] = str(self.hero_id)
|
|
||||||
raw["hero_class"] = self.hero_class.value if self.hero_class else None
|
|
||||||
return _walk_map(raw, _enum_to_value)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> Hero:
|
|
||||||
"""
|
|
||||||
Build Hero from a dict (reverse of to_dict).
|
|
||||||
Accepts hero_class as string or None; hero_id as str or UUID.
|
|
||||||
"""
|
|
||||||
data = dict(data) # shallow copy
|
|
||||||
|
|
||||||
# Parse UUID
|
|
||||||
hero_id_val = data.get("hero_id")
|
|
||||||
if hero_id_val and not isinstance(hero_id_val, UUID):
|
|
||||||
data["hero_id"] = UUID(hero_id_val)
|
|
||||||
|
|
||||||
# Parse enum
|
|
||||||
hc = data.get("hero_class")
|
|
||||||
if isinstance(hc, str) and hc:
|
|
||||||
data["hero_class"] = HeroClass(hc)
|
|
||||||
elif hc is None:
|
|
||||||
data["hero_class"] = None
|
|
||||||
|
|
||||||
# Rebuild nested dataclasses if passed as dicts
|
|
||||||
for k, typ in {
|
|
||||||
"attributes": Attributes,
|
|
||||||
"resources": Resources,
|
|
||||||
"progress": Progress,
|
|
||||||
"location": Location,
|
|
||||||
"equipment": Equipment,
|
|
||||||
"collections": Collections,
|
|
||||||
}.items():
|
|
||||||
if isinstance(data.get(k), dict):
|
|
||||||
data[k] = typ(**data[k])
|
|
||||||
|
|
||||||
hero: Hero = cls(**data) # type: ignore[arg-type]
|
|
||||||
return hero
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# Helpers
|
|
||||||
# -----------------------------
|
|
||||||
|
|
||||||
def _walk_map(obj: Any, f) -> Any:
|
|
||||||
"""Recursively map Python structures with function f applied to leaf values as needed."""
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {k: _walk_map(v, f) for k, v in obj.items()}
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return [_walk_map(x, f) for x in obj]
|
|
||||||
return f(obj)
|
|
||||||
|
|
||||||
def _unique_extend(target: list[str], new_items: Iterable[str]) -> list[str]:
|
|
||||||
"""Append only items not already present; return the list for convenience."""
|
|
||||||
seen = set(target)
|
|
||||||
for x in new_items:
|
|
||||||
if x not in seen:
|
|
||||||
target.append(x)
|
|
||||||
seen.add(x)
|
|
||||||
return target
|
|
||||||
|
|
||||||
def default_xp_curve(current_xp: int, level: int) -> int:
|
|
||||||
"""
|
|
||||||
Replace this with your real XP curve (e.g., g_utils.get_xp_for_next_level).
|
|
||||||
Simple example: mild exponential growth.
|
|
||||||
"""
|
|
||||||
base = 100
|
|
||||||
return base + int(50 * (level ** 1.5)) + (level * 25)
|
|
||||||
|
|
||||||
def _validate_ids_exist(
|
|
||||||
ids: Iterable[str],
|
|
||||||
local_catalog: Mapping[str, Any] | None,
|
|
||||||
kind: str,
|
|
||||||
owner: str,
|
|
||||||
*,
|
|
||||||
registry: Any | None = None, # e.g., SkillRegistry or SpellRegistry with .get()
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Validate that each id is present either in the provided local_catalog OR
|
|
||||||
resolvable via the optional global registry (registry.get(id) not None).
|
|
||||||
"""
|
|
||||||
local_catalog = local_catalog or {}
|
|
||||||
reg_get = getattr(registry, "get", None) if registry is not None else None
|
|
||||||
|
|
||||||
missing: list[str] = []
|
|
||||||
for _id in ids:
|
|
||||||
in_local = _id in local_catalog
|
|
||||||
in_global = bool(reg_get(_id)) if reg_get else False
|
|
||||||
if not (in_local or in_global):
|
|
||||||
missing.append(_id)
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
raise ValueError(f"{owner}: undefined {kind} id(s): {', '.join(missing)}")
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# app/models/primitives.py
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Attributes:
|
|
||||||
base_str: int = 0
|
|
||||||
base_dex: int = 0
|
|
||||||
base_int: int = 0
|
|
||||||
base_wis: int = 0
|
|
||||||
base_luk: int = 0
|
|
||||||
base_cha: int = 0
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Resources:
|
|
||||||
maxhp: int = 0
|
|
||||||
hp: int = 0
|
|
||||||
maxmp: int = 0
|
|
||||||
mp: int = 0
|
|
||||||
gold: int = 0
|
|
||||||
|
|
||||||
def apply_damage(self, amount: int) -> None:
|
|
||||||
"""Reduce HP by amount (cannot go below 0)."""
|
|
||||||
self.hp = _clamp(self.hp - max(0, amount), 0, self.maxhp)
|
|
||||||
|
|
||||||
def heal(self, amount: int) -> None:
|
|
||||||
"""Increase HP by amount (cannot exceed maxhp)."""
|
|
||||||
self.hp = _clamp(self.hp + max(0, amount), 0, self.maxhp)
|
|
||||||
|
|
||||||
def spend_mana(self, amount: int) -> bool:
|
|
||||||
"""Try to spend MP. Returns True if successful."""
|
|
||||||
if amount <= self.mp:
|
|
||||||
self.mp -= amount
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def restore_mana(self, amount: int) -> None:
|
|
||||||
"""Increase MP by amount (cannot exceed maxmp)."""
|
|
||||||
self.mp = _clamp(self.mp + max(0, amount), 0, self.maxmp)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Collections:
|
|
||||||
skills: List[str] = field(default_factory=list)
|
|
||||||
spells: List[str] = field(default_factory=list)
|
|
||||||
achievements: List[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
def _clamp(value: int, lo: int, hi: int) -> int:
|
|
||||||
"""Clamp integer between lo and hi."""
|
|
||||||
return max(lo, min(hi, value))
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# races.py
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Dict, List, Any
|
|
||||||
|
|
||||||
# Reuse your existing types:
|
|
||||||
|
|
||||||
from app.utils.hero_catalog import SkillDef, SpellDef
|
|
||||||
|
|
||||||
from app.models.enums import Race
|
|
||||||
from app.models.primitives import Attributes, Resources
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RacialTrait:
|
|
||||||
id: str
|
|
||||||
data: Dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RaceSheet:
|
|
||||||
key: Race
|
|
||||||
display_name: str
|
|
||||||
|
|
||||||
# Flat bases/mods (additive by default)
|
|
||||||
base_attributes: Attributes = field(default_factory=Attributes)
|
|
||||||
starting_resources: Resources = field(default_factory=Resources)
|
|
||||||
|
|
||||||
# Starting lists (IDs only)
|
|
||||||
starting_skills: List[str] = field(default_factory=list)
|
|
||||||
starting_spells: List[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
# Optional local catalogs (definitions)
|
|
||||||
skills: Dict[str, SkillDef] = field(default_factory=dict)
|
|
||||||
spells: Dict[str, SpellDef] = field(default_factory=dict)
|
|
||||||
|
|
||||||
# Racial traits/abilities with free-form data
|
|
||||||
traits: List[RacialTrait] = field(default_factory=list)
|
|
||||||
@@ -45,11 +45,6 @@ app/
|
|||||||
│ └── utils/
|
│ └── utils/
|
||||||
│ └── common.py
|
│ └── common.py
|
||||||
│
|
│
|
||||||
├── models/ # Shared models for the Flask layer
|
|
||||||
│ ├── hero.py
|
|
||||||
│ ├── enums.py
|
|
||||||
│ └── primitives.py
|
|
||||||
│
|
|
||||||
└── utils/
|
└── utils/
|
||||||
├── catalogs/ # Compiled catalog data for fast lookups
|
├── catalogs/ # Compiled catalog data for fast lookups
|
||||||
│ ├── race_catalog.py
|
│ ├── race_catalog.py
|
||||||
|
|||||||
12
new_hero.py
12
new_hero.py
@@ -4,6 +4,7 @@ import json
|
|||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
from app.game.generators.weapons_factory import WeaponGenerator
|
||||||
from app.game.generators.entity_factory import build_char
|
from app.game.generators.entity_factory import build_char
|
||||||
from app.game.generators.level_progression import DEFAULT_LEVEL_PROGRESSION
|
from app.game.generators.level_progression import DEFAULT_LEVEL_PROGRESSION
|
||||||
from app.game.systems.leveling import grant_xp
|
from app.game.systems.leveling import grant_xp
|
||||||
@@ -14,12 +15,19 @@ player = build_char(
|
|||||||
race_id="terran",
|
race_id="terran",
|
||||||
profession_id="arcanist",
|
profession_id="arcanist",
|
||||||
ability_pathway="Frostbinder",
|
ability_pathway="Frostbinder",
|
||||||
level=50
|
level=4
|
||||||
)
|
)
|
||||||
|
|
||||||
old, new = grant_xp(player,(156),DEFAULT_LEVEL_PROGRESSION)
|
old, new = grant_xp(player,(156),DEFAULT_LEVEL_PROGRESSION)
|
||||||
|
|
||||||
|
# Generate a weapon based on char level
|
||||||
|
weapon_gen = WeaponGenerator()
|
||||||
|
weapon = weapon_gen.generate(char_level=player.level)
|
||||||
|
player.weapons.append(weapon)
|
||||||
|
|
||||||
|
|
||||||
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!
|
||||||
|
|||||||
Reference in New Issue
Block a user