adding weapon gen

This commit is contained in:
2025-11-02 19:46:47 -06:00
parent 31aa0000cc
commit 6efd3b3aa8
15 changed files with 424 additions and 491 deletions

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

View File

@@ -53,5 +53,4 @@ class Entity:
weapons: List[Dict[str, int]] = field(default_factory=list)
armor: 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,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

View 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

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

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

View 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

View 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

View 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

View File

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

View File

@@ -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 youve 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)}")

View File

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

View File

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