from __future__ import annotations import random from dataclasses import dataclass, field from engine.character import BodyPart, Character, ClothingPiece from engine.character_library import CharacterLibrary from engine.defs import DefRegistry from engine.skills import default_bio DEFAULT_SKILL_POINTS = 10 DEFAULT_MAX_SKILL_LEVEL = 5 DEFAULT_ROLL_COUNT = 6 # how many values roll_assign mode generates to place among the skills # The four ways skills can be filled in - see CharacterCreator.set_skill_mode. SKILL_MODE_POINT_BUY = "point_buy" # spend a shared budget freely (increase_skill/decrease_skill) SKILL_MODE_ROLL_ASSIGN = "roll_assign" # roll a small pool of values, place each on a skill you pick SKILL_MODE_MANUAL = "manual" # set any skill to any value directly, no budget SKILL_MODE_RANDOM = "random" # every skill gets its own random value immediately, nothing to place SKILL_MODES = (SKILL_MODE_POINT_BUY, SKILL_MODE_ROLL_ASSIGN, SKILL_MODE_MANUAL, SKILL_MODE_RANDOM) # Species a player can create a character as. "dog" is deliberately excluded - it's a # companion/NPC-only species (see the companion_dog entity template), still fully valid to # spawn as an NPC or load via CharacterLibrary, just not offered as a player choice here. PLAYER_SELECTABLE_SPECIES: tuple[str, ...] = ("human", "reptilian_quad", "alien_tri", "bot") @dataclass class CharacterCreator: """Pure state/logic for building a new persistent character: pick a species, a gender valid for that species (species define their own valid genders - see SpeciesDef.genders), a cosmetic body type and hair color (also species-defined, see SpeciesDef.body_type_options/ hair_colors - either can be empty for a species that doesn't offer that choice, e.g. no species-defined hair colors means no hair to color), starter clothing (species/arm-count compatibility enforced the same way Character.wear_item enforces it - see set_starter_clothing), a name, and a fixed point budget spent across the classic D&D-style bio skills (engine/skills.py) - then confirm() builds an actual Character and saves it into the CharacterLibrary under a given account, ready to appear on the StartScreen. No rendering here - same boundary as StartScreen/CharacterMenu/LayerPickerMenu/AbilityBar (engine/ui.py, engine/start_screen.py): drawing species swatches, a name field, color swatches, and skill +/- steppers is a follow-up UI-layer task. This is the state/logic side: valid choices, point-budget bookkeeping, and producing the finished Character. """ registry: DefRegistry selectable_species: tuple[str, ...] = PLAYER_SELECTABLE_SPECIES skill_points_budget: int = DEFAULT_SKILL_POINTS max_skill_level: int = DEFAULT_MAX_SKILL_LEVEL name: str = "" species_id: str | None = None gender: str | None = None body_type: str | None = None hair_color: str | None = None starter_clothing: dict[str, str] = field(default_factory=dict) # slot -> item_def_id bio: dict[str, int] = field(default_factory=default_bio) skill_mode: str = SKILL_MODE_POINT_BUY rolled_values: list[int] = field(default_factory=list) # roll_assign mode's unplaced pool def set_name(self, name: str) -> None: self.name = name.strip() def available_species(self) -> list[str]: """Species ids a player can actually pick, for a UI to list - selectable_species filtered against whatever's actually loaded in the registry. """ return [s for s in self.selectable_species if s in self.registry.species_defs] def set_species(self, species_id: str) -> bool: """Picks a species, resetting gender/body_type/hair_color to that species' first valid option if the current pick (from a previous species) isn't valid for it, and dropping any starter clothing (fit/compatibility is species-specific - see set_starter_clothing). Rejects any species not in selectable_species (e.g. "dog" - companion/NPC-only, not a player choice) even if it's otherwise a perfectly valid, fully-loaded species. """ if species_id not in self.selectable_species or species_id not in self.registry.species_defs: return False self.species_id = species_id species = self.registry.get_species_def(species_id) if self.gender not in species.genders: self.gender = species.genders[0] if species.genders else None if self.body_type not in species.body_type_options: self.body_type = species.body_type_options[0] if species.body_type_options else None if self.hair_color not in species.hair_colors: self.hair_color = species.hair_colors[0] if species.hair_colors else None self.starter_clothing = {} return True def set_gender(self, gender: str) -> bool: if self.species_id is None: return False species = self.registry.get_species_def(self.species_id) if gender not in species.genders: return False self.gender = gender return True def set_body_type(self, body_type: str) -> bool: if self.species_id is None: return False species = self.registry.get_species_def(self.species_id) if body_type not in species.body_type_options: return False self.body_type = body_type return True def set_hair_color(self, hair_color: str) -> bool: if self.species_id is None: return False species = self.registry.get_species_def(self.species_id) if hair_color not in species.hair_colors: return False self.hair_color = hair_color return True def set_starter_clothing(self, slot: str, item_def_id: str) -> bool: """Picks a starter garment for `slot`, validated exactly the way Character.wear_item validates it (species body_type + arm-sleeve fit) - via a throwaway probe character, so the fit rule only has to live in one place. Requires a species to already be chosen. """ if self.species_id is None: return False probe = Character(species_id=self.species_id, default_body_parts=self.registry.new_default_body_parts(self.species_id)) if not probe.wear_item(slot, item_def_id, self.registry): return False self.starter_clothing[slot] = item_def_id return True def remove_starter_clothing(self, slot: str) -> None: self.starter_clothing.pop(slot, None) def set_skill_mode(self, mode: str, rng: random.Random | None = None) -> bool: """Switches how skills get filled in, resetting bio to a clean slate for that mode: - point_buy: everything back to 0, spend a shared budget freely (increase_skill/ decrease_skill) - the default, classic point-buy. - roll_assign: rolls a fixed pool of DEFAULT_ROLL_COUNT values (0..max_skill_level each) up front; each gets placed on a skill of your choosing via assign_rolled_value, one at a time - whatever skills are left unassigned stay at 0. - manual: every skill directly settable to any value via set_skill_manual, no budget at all - full freeform control. - random: every skill immediately gets its own independent random value - nothing left to place, the character's ready to go as rolled (switching to another mode discards this and starts that mode fresh). """ if mode not in SKILL_MODES: return False self.skill_mode = mode self.bio = default_bio() self.rolled_values = [] rng = rng or random.Random() if mode == SKILL_MODE_ROLL_ASSIGN: self.rolled_values = [rng.randint(0, self.max_skill_level) for _ in range(DEFAULT_ROLL_COUNT)] elif mode == SKILL_MODE_RANDOM: for skill_id in self.bio: self.bio[skill_id] = rng.randint(0, self.max_skill_level) return True def assign_rolled_value(self, roll_index: int, skill_id: str) -> bool: """roll_assign mode only: places one of the still-unplaced rolled values onto a skill (overwriting whatever it currently holds - 0, if untouched) and removes that value from the pool, so each rolled value can only be placed once. """ if self.skill_mode != SKILL_MODE_ROLL_ASSIGN: return False if not (0 <= roll_index < len(self.rolled_values)): return False if skill_id not in self.bio: return False value = self.rolled_values.pop(roll_index) self.bio[skill_id] = value return True def set_skill_manual(self, skill_id: str, value: int) -> bool: """manual mode only: directly sets a skill to any value in [0, max_skill_level].""" if self.skill_mode != SKILL_MODE_MANUAL: return False if skill_id not in self.bio: return False if not (0 <= value <= self.max_skill_level): return False self.bio[skill_id] = value return True def spent_skill_points(self) -> int: return sum(self.bio.values()) def remaining_skill_points(self) -> int: return self.skill_points_budget - self.spent_skill_points() def increase_skill(self, skill_id: str) -> bool: """point_buy mode only - see set_skill_mode for the other three ways to fill in skills.""" if self.skill_mode != SKILL_MODE_POINT_BUY: return False if skill_id not in self.bio: return False if self.remaining_skill_points() <= 0 or self.bio[skill_id] >= self.max_skill_level: return False self.bio[skill_id] += 1 return True def decrease_skill(self, skill_id: str) -> bool: """point_buy mode only - see set_skill_mode for the other three ways to fill in skills.""" if self.skill_mode != SKILL_MODE_POINT_BUY: return False if skill_id not in self.bio or self.bio[skill_id] <= 0: return False self.bio[skill_id] -= 1 return True def preview_body_parts(self) -> list[BodyPart]: """Read-only preview of the anatomy this character will start with, for a UI to render without needing to build a whole Character first. """ if self.species_id is None: return [] return self.registry.new_default_body_parts(self.species_id) def is_ready(self) -> bool: if not self.name or self.species_id is None or self.gender is None: return False species = self.registry.get_species_def(self.species_id) if species.body_type_options and self.body_type is None: return False if species.hair_colors and self.hair_color is None: return False return True def build_character(self) -> Character | None: if not self.is_ready(): return None assert self.species_id is not None return Character( name=self.name, species_id=self.species_id, gender=self.gender, body_type=self.body_type, hair_color=self.hair_color, default_body_parts=self.registry.new_default_body_parts(self.species_id), default_clothing=[ClothingPiece(slot, item_def_id) for slot, item_def_id in self.starter_clothing.items()], bio=dict(self.bio), ) def confirm(self, library: CharacterLibrary, account_id: str, character_id: str) -> Character | None: """Builds the character and saves it into the library under (account_id, character_id) - the "create" action once name/species/gender/skills are all set. No-op (returns None, nothing saved) if the creator isn't ready yet. """ character = self.build_character() if character is None: return None library.save_character(account_id, character_id, character) return character