"""Faction generation system for procedural army/group creation. A Faction is a top-level group with a species, biome, element, and aesthetic. Each faction contains several Roles -- visually and mechanically distinct unit types with unique appearances built from the faction's species layers. Key design: clothes, hair, and skin hues are per-ROLE, not per-faction. The faction defines species and aesthetic; roles define visual specifics. Usage: from shade_sprite.factions import FactionGenerator from shade_sprite.assets import AssetLibrary from shade_sprite.assembler import CharacterAssembler lib = AssetLibrary() gen = FactionGenerator(seed=42, library=lib) recipe = gen.generate() assembler = CharacterAssembler() textures = recipe.build_role_textures(assembler) # textures["melee_fighter"] -> mcrfpy.Texture """ import random from dataclasses import dataclass, field from enum import Enum from .assets import AssetLibrary # --------------------------------------------------------------------------- # Domain enums # --------------------------------------------------------------------------- class Biome(Enum): ICE = "ice" SWAMP = "swamp" GRASSLAND = "grassland" SCRUBLAND = "scrubland" FOREST = "forest" class Element(Enum): FIRE = "fire" WATER = "water" STONE = "stone" AIR = "air" class Aesthetic(Enum): SLAVERY = "slavery" MILITARISTIC = "militaristic" COWARDLY = "cowardly" FANATICAL = "fanatical" class RoleType(Enum): MELEE_FIGHTER = "melee_fighter" RANGED_FIGHTER = "ranged_fighter" SPELLCASTER = "spellcaster" HEALER = "healer" PET_RUSHER = "pet_rusher" PET_FLANKER = "pet_flanker" # --------------------------------------------------------------------------- # Aesthetic -> role generation templates # --------------------------------------------------------------------------- # Each aesthetic defines which roles are generated and their relative counts. # The generator picks from these to produce 3-5 roles per faction. _AESTHETIC_ROLE_POOLS = { Aesthetic.MILITARISTIC: [ RoleType.MELEE_FIGHTER, RoleType.MELEE_FIGHTER, RoleType.RANGED_FIGHTER, RoleType.RANGED_FIGHTER, RoleType.HEALER, ], Aesthetic.FANATICAL: [ RoleType.SPELLCASTER, RoleType.SPELLCASTER, RoleType.MELEE_FIGHTER, RoleType.HEALER, RoleType.PET_RUSHER, ], Aesthetic.COWARDLY: [ RoleType.RANGED_FIGHTER, RoleType.PET_RUSHER, RoleType.PET_FLANKER, RoleType.PET_FLANKER, RoleType.HEALER, ], Aesthetic.SLAVERY: [ RoleType.MELEE_FIGHTER, RoleType.RANGED_FIGHTER, RoleType.SPELLCASTER, RoleType.PET_RUSHER, RoleType.PET_FLANKER, ], } # Headgear class mapping for roles _ROLE_HEADGEAR_CLASS = { RoleType.MELEE_FIGHTER: "melee", RoleType.RANGED_FIGHTER: "range", RoleType.SPELLCASTER: "mage", RoleType.HEALER: "mage", RoleType.PET_RUSHER: None, # pets/allies don't get headgear RoleType.PET_FLANKER: None, } # Clothing style preferences per role _ROLE_CLOTHING_STYLES = { RoleType.MELEE_FIGHTER: ["armour", "viking", "mongol"], RoleType.RANGED_FIGHTER: ["basic", "french", "japanese"], RoleType.SPELLCASTER: ["tunic", "basic"], RoleType.HEALER: ["tunic", "basic"], RoleType.PET_RUSHER: [], # naked or minimal RoleType.PET_FLANKER: [], } # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @dataclass class RoleDefinition: """A visually and mechanically distinct unit type within a faction. Attributes: role_type: The combat/function role species: Species name for this role (main or ally species) skin_hue: Hue shift for the skin layer (degrees) skin_sat: Saturation shift for the skin layer skin_lit: Lightness shift for the skin layer clothing_layers: List of (path, hue, sat, lit) tuples for clothing headgear_layer: Optional (path, hue, sat, lit) for headgear addon_layer: Optional (path, hue, sat, lit) for species add-ons hair_layer: Optional (path, hue, sat, lit) for hairstyle eye_layer: Optional (path, hue, sat, lit) for eyes shoe_layer: Optional (path, hue, sat, lit) for shoes glove_layer: Optional (path, hue, sat, lit) for gloves is_ally: True if this role uses an ally species (not the main species) """ role_type: RoleType species: str skin_hue: float = 0.0 skin_sat: float = 0.0 skin_lit: float = 0.0 clothing_layers: list = field(default_factory=list) headgear_layer: tuple = None addon_layer: tuple = None hair_layer: tuple = None eye_layer: tuple = None shoe_layer: tuple = None glove_layer: tuple = None is_ally: bool = False @property def label(self): """Human-readable label like 'melee_fighter (Human)'.""" ally_tag = " [ally]" if self.is_ally else "" return f"{self.role_type.value} ({self.species}{ally_tag})" @dataclass class FactionRecipe: """A complete faction definition with species, aesthetic, and roles. Attributes: name: Display name for the faction biome: The biome this faction inhabits species: Main species name element: Elemental affinity aesthetic: Behavioral aesthetic ally_species: List of ally species names roles: List of RoleDefinitions seed: The seed used to generate this faction """ name: str biome: Biome species: str element: Element aesthetic: Aesthetic ally_species: list = field(default_factory=list) roles: list = field(default_factory=list) seed: int = 0 def build_role_textures(self, assembler): """Build one composite texture per role using CharacterAssembler. Args: assembler: A CharacterAssembler instance (format will be reused) library: An AssetLibrary to resolve species -> skin paths. If None, skin layers must already be absolute paths in the role's clothing_layers. Returns: dict[str, mcrfpy.Texture]: role label -> texture """ import mcrfpy # deferred import for headless testing without engine textures = {} for role in self.roles: assembler.clear() # Skin layer -- look up from library by species skin_path = role._skin_path if skin_path: assembler.add_layer( skin_path, role.skin_hue, role.skin_sat, role.skin_lit) # Shoe layer if role.shoe_layer: path, h, s, l = role.shoe_layer assembler.add_layer(path, h, s, l) # Clothing layers for path, h, s, l in role.clothing_layers: assembler.add_layer(path, h, s, l) # Glove layer if role.glove_layer: path, h, s, l = role.glove_layer assembler.add_layer(path, h, s, l) # Add-on layer (species ears, horns, etc.) if role.addon_layer: path, h, s, l = role.addon_layer assembler.add_layer(path, h, s, l) # Hair layer if role.hair_layer: path, h, s, l = role.hair_layer assembler.add_layer(path, h, s, l) # Eye layer if role.eye_layer: path, h, s, l = role.eye_layer assembler.add_layer(path, h, s, l) # Headgear layer (on top) if role.headgear_layer: path, h, s, l = role.headgear_layer assembler.add_layer(path, h, s, l) tex_name = f"{self.name}_{role.label}".replace(" ", "_") textures[role.label] = assembler.build(tex_name) return textures # --------------------------------------------------------------------------- # Faction name generation # --------------------------------------------------------------------------- _FACTION_PREFIXES = [ "Iron", "Shadow", "Dawn", "Ember", "Frost", "Vine", "Storm", "Ash", "Gold", "Crimson", "Azure", "Jade", "Silver", "Night", "Sun", "Bone", "Blood", "Thorn", "Dusk", "Star", "Stone", "Flame", "Void", "Moon", "Rust", ] _FACTION_SUFFIXES = [ "Guard", "Pact", "Order", "Clan", "Legion", "Court", "Band", "Wardens", "Company", "Oath", "Fleet", "Circle", "Hand", "Watch", "Speakers", "Reavers", "Chosen", "Vanguard", "Covenant", "Fang", "Spire", "Horde", "Shield", "Tide", "Crown", ] # --------------------------------------------------------------------------- # Generator # --------------------------------------------------------------------------- class FactionGenerator: """Deterministic faction generator driven by a seed and asset library. Args: seed: Integer seed for reproducible generation library: AssetLibrary instance (if None, creates one with auto-detection) """ def __init__(self, seed, library=None): self.seed = seed self.library = library if library is not None else AssetLibrary() self._rng = random.Random(seed) def generate(self): """Produce a complete FactionRecipe with 3-5 roles. Returns: FactionRecipe with fully specified roles """ rng = self._rng # Pick faction attributes biome = rng.choice(list(Biome)) element = rng.choice(list(Element)) aesthetic = rng.choice(list(Aesthetic)) # Pick species available_species = self.library.species if self.library.available else [ "Human", "Orc", "Demon", "Skeleton", "NightElf", "Cyclops"] species = rng.choice(available_species) # Pick 0-2 ally species (different from main) other_species = [s for s in available_species if s != species] n_allies = rng.randint(0, min(2, len(other_species))) ally_species = rng.sample(other_species, n_allies) if n_allies > 0 else [] # Generate name name = rng.choice(_FACTION_PREFIXES) + " " + rng.choice(_FACTION_SUFFIXES) # Generate roles role_pool = list(_AESTHETIC_ROLE_POOLS[aesthetic]) n_roles = rng.randint(3, min(5, len(role_pool))) chosen_role_types = rng.sample(role_pool, n_roles) # Base skin hue for the main species (random starting point) base_skin_hue = rng.uniform(0, 360) roles = [] for i, role_type in enumerate(chosen_role_types): is_pet = role_type in (RoleType.PET_RUSHER, RoleType.PET_FLANKER) # Determine species for this role if is_pet and ally_species: role_species = rng.choice(ally_species) is_ally = True else: role_species = species is_ally = is_pet and not ally_species # Skin hue: small variation from base for same species if role_species == species: skin_hue = (base_skin_hue + rng.uniform(-15, 15)) % 360 else: skin_hue = rng.uniform(0, 360) skin_sat = rng.uniform(-0.15, 0.15) skin_lit = rng.uniform(-0.1, 0.1) # For slavery aesthetic, allies get no clothes and dimmer skin naked = (aesthetic == Aesthetic.SLAVERY and is_ally) if naked: skin_lit = rng.uniform(-0.3, -0.1) role = RoleDefinition( role_type=role_type, species=role_species, skin_hue=skin_hue, skin_sat=skin_sat, skin_lit=skin_lit, is_ally=is_ally, ) # Resolve actual layer files from the library self._assign_skin(role, role_species) if not naked: self._assign_clothing(role, role_type, rng) self._assign_headgear(role, role_type, rng) self._assign_hair(role, rng) self._assign_shoes(role, rng) self._assign_gloves(role, role_type, rng) self._assign_eyes(role, rng) self._assign_addons(role, role_species, rng) roles.append(role) return FactionRecipe( name=name, biome=biome, species=species, element=element, aesthetic=aesthetic, ally_species=ally_species, roles=roles, seed=self.seed, ) # ---- Layer assignment helpers ---- def _assign_skin(self, role, species): """Pick a skin layer file for the species.""" skins = self.library.skins_for(species) if self.library.available else [] if skins: chosen = self._rng.choice(skins) role._skin_path = chosen.path else: role._skin_path = None def _assign_clothing(self, role, role_type, rng): """Pick clothing appropriate to the role type.""" if not self.library.available: return preferred_styles = _ROLE_CLOTHING_STYLES.get(role_type, []) candidates = [] for style in preferred_styles: candidates.extend(self.library.clothes_by_style(style)) if not candidates: candidates = self.library.clothes if not candidates: return chosen = rng.choice(candidates) clothing_hue = rng.uniform(0, 360) role.clothing_layers.append( (chosen.path, clothing_hue, 0.0, rng.uniform(-0.1, 0.05))) def _assign_headgear(self, role, role_type, rng): """Pick a headgear matching the role's combat class.""" if not self.library.available: return hg_class = _ROLE_HEADGEAR_CLASS.get(role_type) if hg_class is None: return candidates = self.library.headgears_for_class(hg_class) if not candidates: candidates = self.library.headgears if not candidates: return chosen = rng.choice(candidates) role.headgear_layer = (chosen.path, 0.0, 0.0, 0.0) def _assign_hair(self, role, rng): """Pick a hairstyle (50% chance for humanoid roles).""" if not self.library.available: return if rng.random() < 0.5: return # no hair / covered by headgear hairs = self.library.hairstyles if not hairs: return # Filter to just actual hairstyles (not facial), pick one head_hairs = [h for h in hairs if "Facial" not in h.subcategory] if not head_hairs: head_hairs = hairs chosen = rng.choice(head_hairs) role.hair_layer = (chosen.path, 0.0, 0.0, 0.0) def _assign_eyes(self, role, rng): """Pick an eye color (80% chance).""" if not self.library.available: return if rng.random() < 0.2: return eye_colors = self.library.layers_in("eyes", "Eye Color") if not eye_colors: return chosen = rng.choice(eye_colors) role.eye_layer = (chosen.path, 0.0, 0.0, 0.0) def _assign_shoes(self, role, rng): """Pick shoes (70% chance).""" if not self.library.available: return if rng.random() < 0.3: return shoes = self.library.shoes if not shoes: return chosen = rng.choice(shoes) role.shoe_layer = (chosen.path, 0.0, 0.0, 0.0) def _assign_gloves(self, role, role_type, rng): """Pick gloves for melee/ranged roles (40% chance).""" if not self.library.available: return if role_type not in (RoleType.MELEE_FIGHTER, RoleType.RANGED_FIGHTER): return if rng.random() < 0.6: return gloves = self.library.gloves if not gloves: return chosen = rng.choice(gloves) role.glove_layer = (chosen.path, 0.0, 0.0, 0.0) def _assign_addons(self, role, species, rng): """Pick species-specific add-ons if available.""" if not self.library.available: return addons = self.library.addons_for(species) if not addons: return # 60% chance to add a species add-on if rng.random() < 0.4: return chosen = rng.choice(addons) role.addon_layer = (chosen.path, 0.0, 0.0, 0.0)