diff --git a/app/game/generators/weapons_factory.py b/app/game/generators/weapons_factory.py new file mode 100644 index 0000000..26aa9d6 --- /dev/null +++ b/app/game/generators/weapons_factory.py @@ -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)) diff --git a/app/game/models/entities.py b/app/game/models/entities.py index e34e0fc..a4fa1c9 100644 --- a/app/game/models/entities.py +++ b/app/game/models/entities.py @@ -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) diff --git a/app/game/models/weapons.py b/app/game/models/weapons.py new file mode 100644 index 0000000..41863c2 --- /dev/null +++ b/app/game/models/weapons.py @@ -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 \ No newline at end of file diff --git a/app/game/templates/weapons/affixes.yaml b/app/game/templates/weapons/affixes.yaml new file mode 100644 index 0000000..274e8a9 --- /dev/null +++ b/app/game/templates/weapons/affixes.yaml @@ -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 diff --git a/app/game/templates/weapons/bases.yaml b/app/game/templates/weapons/bases.yaml new file mode 100644 index 0000000..5ba5dfa --- /dev/null +++ b/app/game/templates/weapons/bases.yaml @@ -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] diff --git a/app/game/templates/weapons/grammar.yaml b/app/game/templates/weapons/grammar.yaml new file mode 100644 index 0000000..0463a31 --- /dev/null +++ b/app/game/templates/weapons/grammar.yaml @@ -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" diff --git a/app/game/templates/weapons/implicits.yaml b/app/game/templates/weapons/implicits.yaml new file mode 100644 index 0000000..6fa02d9 --- /dev/null +++ b/app/game/templates/weapons/implicits.yaml @@ -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 diff --git a/app/game/templates/weapons/rarities.yaml b/app/game/templates/weapons/rarities.yaml new file mode 100644 index 0000000..b1aa627 --- /dev/null +++ b/app/game/templates/weapons/rarities.yaml @@ -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 \ No newline at end of file diff --git a/app/game/templates/weapons/tuning.yaml b/app/game/templates/weapons/tuning.yaml new file mode 100644 index 0000000..f96fee1 --- /dev/null +++ b/app/game/templates/weapons/tuning.yaml @@ -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 diff --git a/app/models/enums.py b/app/models/enums.py deleted file mode 100644 index 4459bdf..0000000 --- a/app/models/enums.py +++ /dev/null @@ -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" diff --git a/app/models/hero.py b/app/models/hero.py deleted file mode 100644 index 4520ea2..0000000 --- a/app/models/hero.py +++ /dev/null @@ -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)}") diff --git a/app/models/primitives.py b/app/models/primitives.py deleted file mode 100644 index 69f5952..0000000 --- a/app/models/primitives.py +++ /dev/null @@ -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)) \ No newline at end of file diff --git a/app/models/races.py b/app/models/races.py deleted file mode 100644 index fd356f6..0000000 --- a/app/models/races.py +++ /dev/null @@ -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) diff --git a/docs/char_gen.md b/docs/char_gen.md index 7c40d88..f1151de 100644 --- a/docs/char_gen.md +++ b/docs/char_gen.md @@ -45,11 +45,6 @@ app/ │ └── 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 diff --git a/new_hero.py b/new_hero.py index 2ccd26c..6cb9ff7 100644 --- a/new_hero.py +++ b/new_hero.py @@ -4,6 +4,7 @@ import json 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.level_progression import DEFAULT_LEVEL_PROGRESSION from app.game.systems.leveling import grant_xp @@ -14,12 +15,19 @@ player = build_char( race_id="terran", profession_id="arcanist", ability_pathway="Frostbinder", - level=50 + level=4 ) 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) -# print(json.dumps(player_dict,indent=True)) +print(json.dumps(player_dict,indent=True)) exit() # MOVE HIT DICE TO WEAPONS!