from __future__ import annotations import json from dataclasses import dataclass, field from pathlib import Path from engine.character import AilmentDef, BodyPart, BodyPartDef, ClothingPiece, Organ, OrganDef from engine.item import ItemDef from engine.species import AbilityDef, SpeciesDef from engine.tile import TILE_LAYERS, TileLayerDef @dataclass(frozen=True) class EntityDef: """A spawn template: who to make (species, for characters), starting clothing, and starting inventory size.""" id: str name: str species_id: str | None = None default_clothing: list[ClothingPiece] = field(default_factory=list) inventory_size: tuple[int, int] | None = None class DefRegistry: def __init__(self): self.tile_layer_defs: dict[tuple[str, str], TileLayerDef] = {} self.item_defs: dict[str, ItemDef] = {} self.body_part_defs: dict[str, BodyPartDef] = {} self.organ_defs: dict[str, OrganDef] = {} self.ailment_defs: dict[str, AilmentDef] = {} self.species_defs: dict[str, SpeciesDef] = {} self.entity_defs: dict[str, EntityDef] = {} @classmethod def load(cls, defs_dir: Path) -> "DefRegistry": registry = cls() registry._load_tiles(defs_dir / "tiles.json") registry._load_items(defs_dir / "items.json") registry._load_body_parts(defs_dir / "body_parts.json") registry._load_organs(defs_dir / "organs.json") registry._load_ailments(defs_dir / "ailments.json") registry._load_species(defs_dir / "species.json") registry._load_entities(defs_dir / "entities.json") return registry def _load_tiles(self, path: Path) -> None: data = json.loads(path.read_text()) for layer in TILE_LAYERS: for def_id, fields in data.get(layer, {}).items(): self.tile_layer_defs[(layer, def_id)] = TileLayerDef( id=def_id, layer=layer, texture=fields.get("texture"), color=tuple(fields.get("color", (255, 0, 255))), walkable=fields.get("walkable", True), blocks_los=fields.get("blocks_los", False), deconstruct_item_id=fields.get("deconstruct_item_id"), is_helm=fields.get("is_helm", False), staircase_direction=fields.get("staircase_direction"), speed_multiplier=fields.get("speed_multiplier", 1.0), ) def _load_items(self, path: Path) -> None: data = json.loads(path.read_text()) for def_id, fields in data.items(): self.item_defs[def_id] = ItemDef( id=def_id, name=fields["name"], width=fields["width"], height=fields["height"], texture=fields.get("texture"), color=tuple(fields.get("color", (255, 0, 255))), stackable=fields.get("stackable", False), max_stack=fields.get("max_stack", 1), clothing_slot=fields.get("clothing_slot"), grants_inventory_size=( tuple(fields["grants_inventory_size"]) if "grants_inventory_size" in fields else None ), armor_reduction=fields.get("armor_reduction", 0.0), arm_slots_required=fields.get("arm_slots_required", 0), compatible_body_types=tuple(fields.get("compatible_body_types", ())), weapon_skill=fields.get("weapon_skill"), weapon_damage=fields.get("weapon_damage", 0.0), weapon_range=fields.get("weapon_range", 1), projectile_type=fields.get("projectile_type"), hands_required=fields.get("hands_required", 1), shield_block_bonus=fields.get("shield_block_bonus", 0.0), blockable_projectile_types=tuple(fields.get("blockable_projectile_types", ())), tool_kind=fields.get("tool_kind"), builds_tile_layer=fields.get("builds_tile_layer"), builds_tile_layer_def_id=fields.get("builds_tile_layer_def_id"), ammo_type=fields.get("ammo_type"), ranged_knockback=fields.get("ranged_knockback", 0.0), compatible_ammo_types=tuple(fields.get("compatible_ammo_types", ())), magazine_size=fields.get("magazine_size", 0), aim_cone_degrees=fields.get("aim_cone_degrees", 0.0), pellet_count=fields.get("pellet_count", 1), blast_radius=fields.get("blast_radius", 0), attachment_kind=fields.get("attachment_kind"), aimcone_reduction_degrees=fields.get("aimcone_reduction_degrees", 0.0), magazine_capacity_bonus=fields.get("magazine_capacity_bonus", 0), melee_knockback=fields.get("melee_knockback", 0.0), grants_ability=fields.get("grants_ability"), is_implant=fields.get("is_implant", False), ) def _load_body_parts(self, path: Path) -> None: data = json.loads(path.read_text()) for def_id, fields in data.items(): self.body_part_defs[def_id] = BodyPartDef( id=def_id, slot=fields["slot"], texture=fields.get("texture"), color=tuple(fields.get("color", (255, 0, 255))), bleeding_weight=fields.get("bleeding_weight", 0.0), bleeding_speed=fields.get("bleeding_speed", 0.0), skill_weights=dict(fields.get("skill_weights", {})), speed_factor=fields.get("speed_factor", 0.0), ) def _load_organs(self, path: Path) -> None: data = json.loads(path.read_text()) for def_id, fields in data.items(): self.organ_defs[def_id] = OrganDef( id=def_id, name=fields.get("name", def_id), host_slot=fields["host_slot"], check_weights=dict(fields.get("check_weights", {})), affects_organs=dict(fields.get("affects_organs", {})), role=fields.get("role"), ) def _load_ailments(self, path: Path) -> None: data = json.loads(path.read_text()) for def_id, fields in data.items(): self.ailment_defs[def_id] = AilmentDef( id=def_id, name=fields.get("name", def_id), check_weights=dict(fields.get("check_weights", {})), ) def _load_species(self, path: Path) -> None: data = json.loads(path.read_text()) for def_id, fields in data.items(): default_parts = [ BodyPart(slot=self.body_part_defs[part_id].slot, part_def_id=part_id) for part_id in fields.get("default_body_parts", []) ] abilities = [ AbilityDef( id=ability["id"], name=ability.get("name", ability["id"]), required_slots=tuple(ability.get("required_slots", ())), ) for ability in fields.get("abilities", []) ] self.species_defs[def_id] = SpeciesDef( id=def_id, name=fields.get("name", def_id), genders=list(fields.get("genders", [])), default_body_parts=default_parts, default_organs=list(fields.get("default_organs", [])), body_type=fields.get("body_type", "biped"), body_type_options=list(fields.get("body_type_options", [])), hair_colors=list(fields.get("hair_colors", [])), is_synthetic=fields.get("is_synthetic", False), abilities=abilities, blood_danger_threshold=fields.get("blood_danger_threshold", 50.0), blood_critical_threshold=fields.get("blood_critical_threshold", 25.0), health_item_compatibility=list(fields.get("health_item_compatibility", [])), ) def _load_entities(self, path: Path) -> None: data = json.loads(path.read_text()) for def_id, fields in data.items(): inventory_size = fields.get("inventory_size") default_clothing = [ ClothingPiece(slot=self.item_defs[item_id].clothing_slot, item_def_id=item_id) for item_id in fields.get("default_clothing", []) ] self.entity_defs[def_id] = EntityDef( id=def_id, name=fields.get("name", def_id), species_id=fields.get("species_id"), default_clothing=default_clothing, inventory_size=tuple(inventory_size) if inventory_size else None, ) def get_tile_layer_def(self, layer: str, def_id: str | None) -> TileLayerDef | None: if def_id is None: return None return self.tile_layer_defs.get((layer, def_id)) def get_item_def(self, def_id: str) -> ItemDef: return self.item_defs[def_id] def get_body_part_def(self, def_id: str) -> BodyPartDef: return self.body_part_defs[def_id] def get_organ_def(self, def_id: str) -> OrganDef: return self.organ_defs[def_id] def get_ailment_def(self, def_id: str) -> AilmentDef | None: """None (not KeyError) for an unrecognized ailment id - not every Ailment instance needs a matching def (e.g. ad-hoc/test-only ailments), see Character.ailment_penalty. """ return self.ailment_defs.get(def_id) def get_species_def(self, def_id: str) -> SpeciesDef: return self.species_defs[def_id] def get_entity_def(self, def_id: str) -> EntityDef: return self.entity_defs[def_id] def new_default_body_parts(self, species_id: str) -> list[BodyPart]: """Fresh BodyPart instances copied from a species' defaults, safe to mutate per-character. Organs are placed per the species' own explicit default_organs list (not by scanning every loaded organ def for a host_slot match) - a species declares exactly which organs it has, same as it declares exactly which body parts it has. This matters once species stop sharing anatomy: e.g. a robotic species with a "torso" slot must NOT automatically inherit an organic heart/lungs/liver/kidneys just because they happen to host there. Each declared organ is still placed into whichever body part its own OrganDef.host_slot names. """ species = self.get_species_def(species_id) organs_by_slot: dict[str, list[Organ]] = {} for organ_def_id in species.default_organs: organ_def = self.organ_defs[organ_def_id] organs_by_slot.setdefault(organ_def.host_slot, []).append(Organ(organ_def_id=organ_def_id)) return [ BodyPart( slot=p.slot, part_def_id=p.part_def_id, organs=organs_by_slot.get(p.slot, []), ) for p in species.default_body_parts ] def new_default_clothing(self, entity_def_id: str) -> list[ClothingPiece]: """Fresh ClothingPiece instances copied from an entity template's starting outfit, safe to mutate per-character.""" return [ ClothingPiece(slot=p.slot, item_def_id=p.item_def_id) for p in self.get_entity_def(entity_def_id).default_clothing ]